maple-agent-tracing-microsoft-agent-framework
GitHub为Microsoft Agent Framework和Semantic Kernel集成Maple可观测性,实现Agent会话追踪、对话记录及Token统计。
Trigger Scenarios
Install
npx skills add MapleTechLabs/maple --skill maple-agent-tracing-microsoft-agent-framework -g -y
SKILL.md
Frontmatter
{
"name": "maple-agent-tracing-microsoft-agent-framework",
"description": "Trace Microsoft Agent Framework and Semantic Kernel agents (Python and .NET) with Maple: native GenAI spans exported over OTLP\/HTTP, plus a span processor that adds the conversation id so each chat is one Agent Session with transcript, tools and tokens. Triggers on 'trace my agent framework agent', 'add Maple to Microsoft Agent Framework', 'add Maple to Semantic Kernel', 'agent sessions for agent-framework', 'OpenTelemetry for Semantic Kernel'."
}
Maple agent tracing: Microsoft Agent Framework and Semantic Kernel
Goal: one user conversation = one Maple Agent Session, with a transcript, every model call, every tool call (arguments, results, failures) and tokens.
The framework emits the spans itself. You add: an OTLP/HTTP exporter, content capture, a gen_ai.conversation.id span processor (the framework never sets one for local-history sessions), and a flush.
Step 0: Detect
- Python MAF:
agent-framework,agent-framework-coreinpyproject.toml/requirements*.txt/uv.lock. Check the installed version (python -c "import agent_framework; print(agent_framework.__version__)"). Target ≥ 1.19.0; upgrade if older (1.13 lacks theotlp_*arguments used below). - .NET MAF:
Microsoft.Agents.AIin*.csproj. Target ≥ 1.22.0. - Semantic Kernel:
semantic-kernel(Python, target ≥ 1.44.1) orMicrosoft.SemanticKernel(.NET). Use the Semantic Kernel section. - Existing OpenTelemetry: search for
TracerProvider(,set_tracer_provider,configure_azure_monitor,logfire.configure,configure_otel_providers,AddOpenTelemetry(,Sdk.CreateTracerProviderBuilder. If a provider exists, add Maple's exporter and the conversation processor to it; do not create a second provider and do not callconfigure_otel_providers(). - Find every place a user message is handled (HTTP route, queue consumer, CLI loop) and what identifies the conversation there (chat id, thread id,
AgentSession). You need it in Step 3.
Step 1: Key and region
- US endpoint
https://ingest.maple.dev, EUhttps://ingest.eu.maple.dev. HeaderAuthorization=Bearer <key>. Protocolhttp/protobuf. - Key in the user's prompt: use it. No key: use the literal
MAPLE_TEST(ingest accepts and discards it) and tell the user to replace it with their key from Settings → Ingestion. - Never put a private
maple_sk_key in browser code. - Follow the repo's existing secret/env convention (
.env, settings class, user-secrets). If there is none, inlining the ingest key is acceptable: ingest keys are write-only. - Key from an env var: when it's unset, log one warning (
MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled) and skip the Maple exporter so the app runs normally (Step 2 shows it). Never raise, exit or throw over the key, no bareKeyErroronos.environ['MAPLE_INGEST_KEY'], and never pass a nullEnvironment.GetEnvironmentVariable(...)into the .NET header (Bearerwith no key is an opaque 401). - App loads
.env(load_dotenv()): call it beforeconfigure_otel_providers()/ the provider is built, and before reading the key. Otherwise the exporter silently targetslocalhost:4318with no key.
Step 2: Install and init
Python MAF
pip install "agent-framework-core>=1.19.0" "agent-framework-openai>=1.14.4" opentelemetry-exporter-otlp-proto-http
(Keep the agent-framework meta-package if the project already uses it; still add opentelemetry-exporter-otlp-proto-http, MAF installs no exporter.)
Create maple_tracing.py next to the app entry point:
from contextlib import contextmanager
from contextvars import ContextVar
from opentelemetry.sdk.trace import SpanProcessor
_conversation_id: ContextVar[str | None] = ContextVar("conversation_id", default=None)
class ConversationIdProcessor(SpanProcessor):
"""Puts gen_ai.conversation.id on every span started inside `conversation()`."""
def on_start(self, span, parent_context=None):
if (conversation_id := _conversation_id.get()) is not None:
span.set_attribute("gen_ai.conversation.id", conversation_id)
@contextmanager
def conversation(conversation_id: str):
token = _conversation_id.set(conversation_id)
try:
yield
finally:
_conversation_id.reset(token)
At startup, once, before agents are created:
import logging
import os
from agent_framework.observability import configure_otel_providers
from opentelemetry import trace
from maple_tracing import ConversationIdProcessor
key = os.environ.get("MAPLE_INGEST_KEY")
if key:
configure_otel_providers(
service_name="<service-name>",
resource_attributes={"deployment.environment.name": "<env>"},
otlp_endpoint="https://ingest.maple.dev",
otlp_protocol="http/protobuf",
otlp_headers={"Authorization": f"Bearer {key}"},
enable_sensitive_data=True,
enable_message_events=False,
)
trace.get_tracer_provider().add_span_processor(ConversationIdProcessor())
else:
# A missing key disables export; it never stops the app.
logging.getLogger(__name__).warning("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled")
Equivalent env-var config, with a bare configure_otel_providers() call:
export OTEL_SERVICE_NAME="<service-name>"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <key>"
export ENABLE_SENSITIVE_DATA="true"
export ENABLE_MESSAGE_EVENTS="false"
otlp_protocolis mandatory: MAF defaults to gRPC (spec says http/protobuf) and fails silently or with an ImportError.configure_otel_providers()appends/v1/traces,/v1/metrics,/v1/logsto the endpoint.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, if set, is used as-is and must include/v1/traces.- Existing provider instead: add
BatchSpanProcessor(OTLPSpanExporter(endpoint="https://ingest.maple.dev/v1/traces", headers={...}))andConversationIdProcessor()to it, thenfrom agent_framework.observability import enable_instrumentation; enable_instrumentation(enable_sensitive_data=True, enable_message_events=False). - For OpenRouter or any Chat Completions endpoint use
OpenAIChatCompletionClient(model=..., api_key=..., base_url=...), notOpenAIChatClient(Responses API; OpenRouter rejects itsprevious_response_idon turn 2).
.NET MAF
dotnet add package Microsoft.Agents.AI --version 1.22.0
dotnet add package Microsoft.Agents.AI.OpenAI --version 1.22.0
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol --version 1.19.1
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.ConfigureResource(r => r.AddService("<service-name>"))
.AddSource("*Microsoft.Agents.AI*")
.AddSource("*Microsoft.Extensions.AI")
.AddProcessor(new ConversationIdProcessor())
.AddOtlpExporter(o =>
{
o.Endpoint = new Uri("https://ingest.maple.dev/v1/traces");
o.Protocol = OtlpExportProtocol.HttpProtobuf;
o.Headers = "Authorization=Bearer <key>";
})
.Build();
AIAgent agent = chatClient
.AsAIAgent(instructions: "...", name: "<agent_name>", tools: [...])
.AsBuilder()
.UseOpenTelemetry(configure: a => a.EnableSensitiveData = true)
.Build();
sealed class ConversationIdProcessor : BaseProcessor<Activity>
{
public static readonly AsyncLocal<string?> Current = new();
public override void OnStart(Activity activity)
{
if (Current.Value is { } id) activity.SetTag("gen_ai.conversation.id", id);
}
}
- Default source names carry an
Experimental.prefix;AddSource("Microsoft.Agents.AI.*")matches nothing. Keep the leading*. UseOpenTelemetry()on the agent (1.22) also instruments its chat client. Do not add a secondUseOpenTelemetry()on theIChatClient.- Name every tool:
AIFunctionFactory.Create(GetWeather, name: "get_weather"). Withoutname:, a local function in top-levelProgram.csis exported as_Main_g_GetWeather_0_3. - Workflows:
.WithOpenTelemetry()on theWorkflowBuilder(sourceMicrosoft.Agents.AI.Workflows, matched by the wildcard). - In a hosted app, put the same sources, processor and exporter in
builder.Services.AddOpenTelemetry().WithTracing(...).
Step 3: Conversation id
MAF only sets gen_ai.conversation.id from the provider's own conversation id (AgentSession.service_session_id: Responses API with store=True, or a Foundry agent owning the thread). With Chat Completions, OpenRouter, Ollama or local AgentSession history it is never set, and AgentSession.session_id is not exported, so every agent.run() becomes its own trace:<id> session.
Wrap every request/turn so all spans of that turn start inside conversation(<id>):
async def handle_message(session: AgentSession, text: str) -> str:
with conversation(session.session_id):
response = await agent.run(text, session=session)
return response.text
- Id: the app's chat/thread id, or
AgentSession.session_id(create sessions withagent.create_session(session_id=chat_id)when the app has an id). Must be stable across all turns of one conversation and differ between conversations. - Streaming: the whole
async for update in agent.run(..., stream=True)loop goes inside thewith. - Approval resumes (
request.to_function_approval_response(...)passed back toagent.run) and workflow runs go inside the samewith. 1.19 logs a WARN "Ignored an approval response ... did not match" on each resume even though the tool runs; ignore it if theexecute_toolspan is there once. - Workflow executors (fan-out included) inherit the id.
- .NET:
ConversationIdProcessor.Current.Value = chatId;in the request handler beforeRunAsync/RunStreamingAsync.
Step 4: Content
- Python:
enable_sensitive_data=True(orENABLE_SENSITIVE_DATA=true). .NET:EnableSensitiveData = true(orOTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true). - If the process sets
OTEL_SEMCONV_STABILITY_OPT_IN, it must includegen_ai_latest_experimental(e.g.http,gen_ai_latest_experimental); otherwise MAF moves content off the spans into log events Maple doesn't read. enable_message_events=False: otherwise every message is also exported as OTLP log records (duplicate payload).gen_ai.tool.definitions(each tool's JSON schema) is sent on everyinvoke_agentspan even with sensitive data off.- If the user wants no prompt content stored, leave sensitive data off and tell them the transcript and tool arguments/results will be empty.
Step 5: Tools, errors, sub-agents
- Give every
Agenta distinctname(Maple lanes key ongen_ai.agent.name; unnamed agents get a UUID). - Tool failures: raising from the tool function is enough; MAF sets ERROR +
error.typeonexecute_tool. Do not catch and return an error string from the tool body (that hides the failure). - Python MAF also logs each tool failure as an ERROR and a WARN log record;
configure_otel_providers()exports them as OTLP logs next to the span. - Approval-gated tools (
@tool(approval_mode="always_require")): the resume is a newagent.run()and trace; it joins the session only insideconversation(). A rejected call emits noexecute_toolspan; the rejection only appears in the nextchatspan's input messages, and only with sensitive data on. - Workflows record fan-in as span links, so parallel executors are siblings under
workflow.run. - Sub-agents: prefer
worker.as_tool()in the orchestrator'stools=[...], or MAF workflows/orchestrations. Do not also instrument the provider SDK (e.g. OpenInference/OpenLLMetry OpenAI instrumentors): that double-counts every call.
Step 6: Flush
Scripts, CLIs, notebooks, tests, serverless:
from opentelemetry import _logs, metrics, trace
try:
asyncio.run(main())
finally:
for provider in (trace.get_tracer_provider(), metrics.get_meter_provider(), _logs.get_logger_provider()):
provider.shutdown()
Serverless handler kept warm: trace.get_tracer_provider().force_flush() before returning. Long-running servers: nothing per request. .NET: using var tracerProvider disposes at exit; hosted apps flush on graceful shutdown.
Semantic Kernel
Python (≥ 1.44.1; uv needs --prerelease=allow for its azure-ai-agents dependency):
pip install "semantic-kernel>=1.44.1" opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
In a module imported before anything that imports semantic_kernel:
import logging
import os
os.environ["SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS"] = "true"
os.environ["SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE"] = "true"
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from maple_tracing import ConversationIdProcessor
provider = TracerProvider(resource=Resource.create({"service.name": "<service-name>"}))
provider.add_span_processor(ConversationIdProcessor())
key = os.environ.get("MAPLE_INGEST_KEY")
if key:
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(
endpoint="https://ingest.maple.dev/v1/traces",
headers={"Authorization": f"Bearer {key}"},
)))
else:
# A missing key disables export; it never stops the app.
logging.getLogger(__name__).warning("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled")
trace.set_tracer_provider(provider)
- The env vars are read once at import; set later, SK emits no GenAI spans and no warning.
- Wrap each turn in
with conversation(thread.id):(ChatHistoryAgentThread().idis stable; SK never exports it). - Call agents with positional messages:
await agent.get_response(text, thread=thread),agent.invoke_stream(text, thread=thread). The keyword formmessages=records empty input. - Transcript comes only from
invoke_agentspans (ChatCompletionAgent). Model-call content is Python logging, which Maple doesn't read; code that uses the kernel without an agent has no transcript. Tell the user. - Orchestrations on
InProcessRuntime: wrap inwith conversation(id), tracer.start_as_current_span("<run name>"):and callruntime.start()inside it, or the run splits into many traces. - Flush with
provider.shutdown()infinally. - Other SK differences to expect (not setup bugs): tool spans are
execute_tool <Plugin>-<function>; failing tools get ERROR +error.typebut no result attribute; finish reasons are Python enum names (FinishReason.STOP), so Maple's reply-length and refusal checks can't read them;temperature=0is omitted from spans. - .NET SK: same env vars (or
AppContextswitchesMicrosoft.SemanticKernel.Experimental.GenAI.EnableOTelDiagnostics[Sensitive]),AddSource("Microsoft.SemanticKernel*"), sameConversationIdProcessor.
Step 7: Verify
Run one real conversation (2-3 turns, one tool call; a second conversation if cheap). No scriptable entry point (server, UI, REPL only): write a small driver for this run (one conversation id, 2+ turns, one tool call, flush before exit). With a real key, open Agent Sessions (https://app.maple.dev/agent-sessions, EU app.eu.maple.dev) after ~1 minute, or with the Maple MCP list_agent_sessions with search=<conversation id> returns one row. Without Maple access (MAPLE_TEST, no MCP): the run must exit with no export errors on stderr (Failed to export, 401 lines) AND a local exporter (ConsoleSpanExporter added temporarily, or the endpoint pointed at a local collector) must show the spans below with gen_ai.conversation.id. Silence alone proves nothing (no spans is silent too). A 401 ingest_unauthorized / "Invalid ingest key" with a key you trust: keys are region-bound, so it likely belongs to the other region; try the other endpoint. Check:
- Spans arrive and the process exits cleanly (flush ran);
service.nameis yours, notagent_framework/unknown_service. - Every span of every turn in one conversation has the same
gen_ai.conversation.id; a second conversation has a different one. In Maple: one session per conversation, nottrace:<id>sessions. - Each turn:
invoke_agent <name>root (.NET:invoke_agent <name>(<agent id>)) withchat <model>andexecute_tool <tool>descendants in the same trace (streamed turn included). chatspans:gen_ai.request.model,gen_ai.usage.input_tokens,gen_ai.usage.output_tokens(also on the streamed turn),gen_ai.input.messages/gen_ai.output.messagesas JSON arrays of{role, parts}(Python MAF; SK: oninvoke_agentonly).execute_tool: real tool name,gen_ai.tool.call.argumentsandgen_ai.tool.call.result; a raising tool has ERROR status +error.type, successful ones don't.- Sub-agents have distinct
gen_ai.agent.names. - No attribute contains the model API key or
Bearer. - Cost: none is emitted; Maple shows sessions as unpriced. Framework label: "Microsoft Agent Framework" / "Semantic Kernel".
Tokens and cost notes
chatspans carrygen_ai.usage.input_tokens/output_tokensplus cache-read, cache-creation and reasoning buckets when the provider returns them. The OpenAI chat-completions client requestsinclude_usageitself when streaming.invoke_agentrepeats the sum of its ownchatcalls; Maple nets it out, so don't strip it.gen_ai.provider.nameis the client type (openaieven when pointed at OpenRouter or Ollama);server.addressholds the real base URL.- .NET streamed
chatspans carrygen_ai.response.time_to_first_chunk(time to first token in Maple); Python MAF doesn't record it.
Do not
- Do not pass
conversation_idas an agent/run option to label traces: it disables in-memory history and becomesprevious_response_idon the Responses client. - Do not pass a bare string as
Message("user", text)contents; use[text].
Version History
- 27a645c Current 2026-10-18 23:11


