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'.
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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.
agent-framework, agent-framework-core in pyproject.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 the otlp_* arguments used below).Microsoft.Agents.AI in *.csproj. Target ≥ 1.22.0.semantic-kernel (Python, target ≥ 1.44.1) or Microsoft.SemanticKernel (.NET). Use the Semantic Kernel section.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 call configure_otel_providers().AgentSession). You need it in Step 3.https://ingest.maple.dev, EU https://ingest.eu.maple.dev. Header Authorization=Bearer <key>. Protocol http/protobuf.MAPLE_TEST (ingest accepts and discards it) and tell the user to replace it with their key from Settings → Ingestion.maple_sk_ key in browser code..env, settings class, user-secrets). If there is none, inlining the ingest key is acceptable: ingest keys are write-only.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 bare KeyError on os.environ['MAPLE_INGEST_KEY'], and never pass a null Environment.GetEnvironmentVariable(...) into the .NET header (Bearer with no key is an opaque 401)..env (load_dotenv()): call it before configure_otel_providers() / the provider is built, and before reading the key. Otherwise the exporter silently targets localhost:4318 with no key.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_protocol is 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/logs to the endpoint. OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, if set, is used as-is and must include /v1/traces.BatchSpanProcessor(OTLPSpanExporter(endpoint="https://ingest.maple.dev/v1/traces", headers={...})) and ConversationIdProcessor() to it, then from agent_framework.observability import enable_instrumentation; enable_instrumentation(enable_sensitive_data=True, enable_message_events=False).OpenAIChatCompletionClient(model=..., api_key=..., base_url=...), not OpenAIChatClient (Responses API; OpenRouter rejects its previous_response_id on turn 2).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.1using 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);
}
}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 second UseOpenTelemetry() on the IChatClient.AIFunctionFactory.Create(GetWeather, name: "get_weather"). Without name:, a local function in top-level Program.cs is exported as _Main_g_GetWeather_0_3..WithOpenTelemetry() on the WorkflowBuilder (source Microsoft.Agents.AI.Workflows, matched by the wildcard).builder.Services.AddOpenTelemetry().WithTracing(...).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.textAgentSession.session_id (create sessions with agent.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.async for update in agent.run(..., stream=True) loop goes inside the with.request.to_function_approval_response(...) passed back to agent.run) and workflow runs go inside the same with. 1.19 logs a WARN "Ignored an approval response ... did not match" on each resume even though the tool runs; ignore it if the execute_tool span is there once.ConversationIdProcessor.Current.Value = chatId; in the request handler before RunAsync/RunStreamingAsync.enable_sensitive_data=True (or ENABLE_SENSITIVE_DATA=true). .NET: EnableSensitiveData = true (or OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true).OTEL_SEMCONV_STABILITY_OPT_IN, it must include gen_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 every invoke_agent span even with sensitive data off.Agent a distinct name (Maple lanes key on gen_ai.agent.name; unnamed agents get a UUID).error.type on execute_tool. Do not catch and return an error string from the tool body (that hides the failure).configure_otel_providers() exports them as OTLP logs next to the span.@tool(approval_mode="always_require")): the resume is a new agent.run() and trace; it joins the session only inside conversation(). A rejected call emits no execute_tool span; the rejection only appears in the next chat span's input messages, and only with sensitive data on.workflow.run.worker.as_tool() in the orchestrator's tools=[...], or MAF workflows/orchestrations. Do not also instrument the provider SDK (e.g. OpenInference/OpenLLMetry OpenAI instrumentors): that double-counts every call.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.
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-httpIn 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)with conversation(thread.id): (ChatHistoryAgentThread().id is stable; SK never exports it).await agent.get_response(text, thread=thread), agent.invoke_stream(text, thread=thread). The keyword form messages= records empty input.invoke_agent spans (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.InProcessRuntime: wrap in with conversation(id), tracer.start_as_current_span("<run name>"): and call runtime.start() inside it, or the run splits into many traces.provider.shutdown() in finally.execute_tool <Plugin>-<function>; failing tools get ERROR + error.type but no result attribute; finish reasons are Python enum names (FinishReason.STOP), so Maple's reply-length and refusal checks can't read them; temperature=0 is omitted from spans.AppContext switches Microsoft.SemanticKernel.Experimental.GenAI.EnableOTelDiagnostics[Sensitive]), AddSource("Microsoft.SemanticKernel*"), same ConversationIdProcessor.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:
service.name is yours, not agent_framework / unknown_service.gen_ai.conversation.id; a second conversation has a different one. In Maple: one session per conversation, not trace:<id> sessions.invoke_agent <name> root (.NET: invoke_agent <name>(<agent id>)) with chat <model> and execute_tool <tool> descendants in the same trace (streamed turn included).chat spans: 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.messages as JSON arrays of {role, parts} (Python MAF; SK: on invoke_agent only).execute_tool: real tool name, gen_ai.tool.call.arguments and gen_ai.tool.call.result; a raising tool has ERROR status + error.type, successful ones don't.gen_ai.agent.names.Bearer .chat spans carry gen_ai.usage.input_tokens / output_tokens plus cache-read, cache-creation and reasoning buckets when the provider returns them. The OpenAI chat-completions client requests include_usage itself when streaming.invoke_agent repeats the sum of its own chat calls; Maple nets it out, so don't strip it.gen_ai.provider.name is the client type (openai even when pointed at OpenRouter or Ollama); server.address holds the real base URL.chat spans carry gen_ai.response.time_to_first_chunk (time to first token in Maple); Python MAF doesn't record it.conversation_id as an agent/run option to label traces: it disables in-memory history and becomes previous_response_id on the Responses client.Message("user", text) contents; use [text].MapleTechLabs/maple
f1305bd
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.