Observability¶
The runtime emits OpenTelemetry traces and metrics so you can see what an agent and its tools actually did — and tell whether a failure is the app's fault or the platform's — without writing any telemetry code. This page explains how to turn it on, what we emit, and how the attribute names are structured.
App authors don't need to read this to get value: with telemetry enabled, the standard Application Insights views light up automatically. The optional dashboards/workbooks that make monitoring even easier are in Appendix A.
Turn it on (no code)¶
The app's function_app.py stays two lines — you enable telemetry through configuration only.
Minimum (path of least resistance):
APPLICATIONINSIGHTS_CONNECTION_STRING— already present on most function apps.- Install the worker exporter extra:
pip install azurefunctions-agents-runtime[monitor]. The runtime configures Azure Monitor automatically when that extra is installed and no OpenTelemetry provider is already active.
That's it — when a real OpenTelemetry provider is active, the runtime's agent.run /
dynamic_session.execute spans and metrics flow to Application Insights. Without the [monitor]
extra, the runtime's worker spans are not exported unless another OpenTelemetry provider is already
active, and if a connection string is set in that state the runtime logs a warning telling you to
install azurefunctions-agents-runtime[monitor].
Optional — unified host + worker correlation:
Add "telemetryMode": "OpenTelemetry" to host.json if you also want the Functions host to
export in OpenTelemetry format, so host request telemetry and the runtime's worker spans share one
end-to-end operation. This is additive — it is not required for the agent/tool spans
themselves. Avoid turning on both a worker exporter and host worker-streaming
(PYTHON_APPLICATIONINSIGHTS_ENABLE_TELEMETRY) for the same data, which can double telemetry volume.
The runtime now auto-detects an already-configured OpenTelemetry provider and skips its own Azure
Monitor setup, so the worker path will not double-export — though it is still unnecessary when the
runtime is already configuring the exporter.
The runtime bootstraps everything from create_function_app()
(_observability.configure_observability()): it enables Microsoft Agent Framework (MAF) gen_ai
instrumentation and, when the [monitor] extra is installed and no OpenTelemetry provider is
already active, the Azure Monitor exporter. When no OpenTelemetry provider is active it is a no-op.
Where the runtime's output shows up (traces, not logs)¶
The runtime is instrumented span-first, so its telemetry surfaces as spans — look in
Transaction Search / the end-to-end transaction view, not the "Logs" (AppTraces) list.
The runtime's own log lines are intentionally not in your Application Insights traces: its
internal logger sits under the azure.functions.* namespace, which the Functions Python worker
classifies as system logs (emitted with customer_app_insight = false), so they never reach your
app's traces. host.json logLevel does not surface them, and worker-side DEBUG would
require the PYTHON_ENABLE_DEBUG_LOGGING app setting. This is by design.
So to debug a run, use the spans — not the log list:
agent.run {name}anddynamic_session.execute, with all theaf.*attributes below.- Runtime span events on
agent.run(input/response-contract milestones — see that span's section) and MAF'sgen_ai.*child spans (per model/tool call). - Failures are captured on the span, not just in logs: a failing run is marked error with
record_exception+af.fault_domain, so it is fully visible in the transaction view and theAppExceptionstable.
The host lifecycle lines you do see in the logs list (Executing…, Executed… (Succeeded),
Function duration…) come from the Functions host — a separate telemetry plane from the
runtime's worker spans. Use the Quick KQL below to pull a whole run together.
Attribute naming: what af. means¶
Every attribute the runtime adds is prefixed af. — short for Azure Functions agents.
The prefix:
- keeps our attributes from colliding with MAF's
gen_ai.*or OpenTelemetry semantic conventions, - makes them trivial to query — everything we add starts with
af..
Four sub-namespaces group the detail, plus two cross-cutting attributes:
| Namespace | Used for |
|---|---|
af.agent.* |
attributes on the per-run agent.run {name} span |
af.dynamic_session.* |
attributes on the dynamic_session.execute (code sandbox) span |
af.web_request.* |
attributes on the web_request (outbound HTTP tool) span |
af.delegate.* |
attributes on the execute_tool delegate_<slug> span (chat-time sub-agent delegation) |
af.fault_domain, af.lifecycle_stage |
cross-cutting; may appear on any runtime span |
Where a standard OpenTelemetry attribute already exists we reuse it instead of inventing an af.
name — for example server.address for the session-pool host. MAF keeps emitting its own
gen_ai.* spans (agent invocation, chat, tool calls, token usage); we don't touch those.
Spans and attributes we emit today¶
Cross-cutting af.* (any runtime span)¶
| Attribute | Meaning |
|---|---|
af.fault_domain |
Whose fault a failure is: app, runtime, platform, model, connector, sandbox, web_request, delegate, unknown. Set only on failing spans. |
af.lifecycle_stage |
Which run stage the span represents, e.g. agent_run, tool_execution. |
Span agent.run {name}¶
One per agent invocation (timer, connector, HTTP, …). It is the parent that ties the MAF gen_ai
spans and the sandbox/web_request tool spans together.
| Attribute | Meaning |
|---|---|
af.agent.name |
Agent name. |
af.agent.trigger_type |
timer, connectorTrigger, http, … |
af.agent.model |
Model/deployment used. |
af.agent.session_id |
Conversation/session id. |
af.agent.outcome |
success or error. |
af.agent.tool_call_count |
Number of tool calls in the run. |
af.agent.tool_error_count |
Tool calls that failed — includes a "successful" call whose result carried an error or non-empty stderr, plus any delegate_<slug> call the coordinator recovered from (a specialist failure or timeout — see Span execute_tool delegate_<slug>). |
af.agent.input_bytes |
Size of the trigger payload / HTTP body. |
af.agent.response_bytes |
Size of the model's final response. |
af.agent.input |
The trigger payload / HTTP body. Content — only when ENABLE_SENSITIVE_DATA=true. |
af.agent.response |
The final response text. Content — only when ENABLE_SENSITIVE_DATA=true. |
Plus af.lifecycle_stage=agent_run, and af.fault_domain if the run fails.
Span events (runtime lifecycle milestones)¶
These agent.run {name} span events mark runtime-owned input/output-contract boundaries. They carry
only non-sensitive metadata (names/status/counts — never request/response/model content). MAF's
gen_ai.* child spans already cover per-model/per-tool detail, so these events intentionally track
runtime milestones rather than duplicating tool/model spans.
af.input.validation_failed— HTTP input-schema validation failed before agent execution; includesaf.fault_domain=appandaf.http.status_code.af.response.invalid_json— the agent completed, but its HTTP response could not be parsed as the required JSON contract; includesaf.fault_domain=app.af.response.schema_validation_failed— the agent completed, but the parsed HTTP JSON response failed schema validation; includesaf.fault_domain=app.af.agent.invoke.completed—_run_agent(...)returned successfully and the runtime is handling the final response contract.
Span dynamic_session.execute¶
One per execute_python call, as a child of agent.run.
| Attribute | Meaning |
|---|---|
server.address |
Session-pool host (OTel semconv). |
af.operation_id |
Correlation id also sent to ACA in the operation-id header. |
af.dynamic_session.session_id |
ACA dynamic-session id. |
af.dynamic_session.code_bytes |
Size of the submitted code. |
af.dynamic_session.stdout_bytes |
Size of stdout. |
af.dynamic_session.stderr_bytes |
Size of stderr. |
af.dynamic_session.stderr_present |
true ⇒ the code failed (this is what used to be invisible). |
af.dynamic_session.session_reused |
Whether the ACA session already existed. |
af.dynamic_session.setup_ran |
Whether the one-time session setup ran this call. |
af.dynamic_session.code / .stdout / .stderr |
Content — only when ENABLE_SENSITIVE_DATA=true. |
Plus af.lifecycle_stage=tool_execution. When stderr is present or the call throws, the span is
marked ERROR with af.fault_domain=sandbox. This is the key fix: a broken execution no longer
looks like a successful tool call.
Span web_request¶
One per web_request tool call, as a child of agent.run. Attributes are deliberately
host-only — the full URL (with query string, and any userinfo) is never attached to the span,
and secrets are never logged, regardless of ENABLE_SENSITIVE_DATA.
| Attribute | Meaning |
|---|---|
http.request.method |
HTTP verb used (GET, POST, …). |
server.address |
Target host (OTel semconv) — set once the SSRF validator has approved a host. |
url.scheme |
http or https. |
http.response.status_code |
Response status code, when a response was received. |
af.web_request.blocked_reason |
Present only when the SSRF validator rejects the request (e.g. private_ip, imds, allowlist_denied). |
af.web_request.response_bytes |
Size of the response body actually read (before truncation applies). |
af.web_request.body_truncated |
true when the response exceeded max_response_bytes and was truncated. |
Plus af.lifecycle_stage=tool_execution. SSRF rejections, timeouts, and transport errors all mark
the span ERROR with af.fault_domain=web_request.
Span execute_tool delegate_<slug> (chat-time sub-agent delegation)¶
Chat-time delegation (FRD 0007) needs no new span —
the delegate_<slug> tool's handler calls the specialist's plain, non-streaming Agent.run(task)
directly, and MAF already traces every Agent.run() and
every FunctionTool.invoke(). A coordinator that declares subagents: gets this nested span tree
for free the moment a delegate_<slug> tool is called:
agent.run {coordinator} runtime span (af.*)
└─ invoke_agent {coordinator} MAF
├─ chat {model} the routing decision
└─ execute_tool delegate_<slug> the delegation (an ordinary tool span)
└─ invoke_agent {specialist} auto-nested
└─ chat {model} the specialist's own model call
All of these spans share one trace, so Application Insights ties the whole fan-out together under a
single OperationId — including concurrent specialists (asyncio.gather), because OpenTelemetry
context propagates through contextvars into each gathered task.
The runtime does not open a new span for delegation — it annotates the existing
execute_tool delegate_<slug> span (already opened by MAF's FunctionTool.invoke()) with
af.delegate.* attributes, the same way agent.run is annotated, for parity with the
sandbox/web_request tools:
| Attribute | Meaning |
|---|---|
af.delegate.specialist |
The specialist's slug (the same identity used for its delegate_<slug> tool name). |
af.delegate.outcome |
success, error, timeout, or cancelled. |
af.delegate.task_bytes |
Size of the task argument passed to the specialist. |
af.delegate.response_bytes |
Size of the specialist's response text (only set on success). |
af.delegate.task / .result |
Content — only when ENABLE_SENSITIVE_DATA=true. |
Plus af.fault_domain=delegate on a failing span: the specialist raised (including a failure
constructing the specialist itself), or the effective
delegation timeout — min(specialist timeout, coordinator's remaining time) — was exceeded. A
parent/request cancellation (asyncio.CancelledError) is different: the handler tags the span
outcome=cancelled and still counts it in the delegate call metric (it was genuinely dispatched),
but never converts it into a recoverable error — it re-raises immediately and aborts the whole run,
rather than being recorded as a delegate error (FRD 0007 Decision #12).
Error accounting. _looks_like_tool_error (the sandbox/web_request JSON {"error": …} /
non-empty-stderr heuristic) does not understand a specialist's sanitized free-text failure message,
so relying on it alone would silently under-count. The delegated adapter tracks its own recoverable
failures explicitly and folds them into af.agent.tool_error_count on top of the heuristic's count.
Accepted limitations (v1):
- SSE is a black box at the boundary. The coordinator's stream emits tool_start/tool_end for
delegate_<slug> (task in, final text out) exactly like the sandbox/web_request tools — a
specialist's own internal deltas and nested tool calls do not surface on the wire unless a MAF
stream_callback is wired into run_agent_stream (out of scope for v1).
- Token usage does not roll up across the boundary. MAF records usage per-run on each
invoke_agent/chat span; the execute_tool span carries no usage, and a specialist's tokens are
not merged into the coordinator's totals. Sum the child spans by trace (OperationId) in the
backend for a combined per-request total.
Metrics¶
Namespace azure_functions_agents.*:
| Metric | Meaning |
|---|---|
azure_functions_agents.dynamic_session.executions |
Count of execute_python calls. |
azure_functions_agents.dynamic_session.errors |
Count that failed or produced stderr. |
azure_functions_agents.web_request.requests |
Count of web_request tool calls. |
azure_functions_agents.web_request.errors |
Count that were blocked by the SSRF validator, timed out, or otherwise failed. |
azure_functions_agents.delegate.calls |
Count of delegate_<slug> tool invocations (chat-time sub-agent delegation). |
azure_functions_agents.delegate.errors |
Count that failed, raised, or timed out (specialist-side; sanitized before reaching the model). |
Sensitive data¶
Sensitive-data capture is controlled by the single ENABLE_SENSITIVE_DATA environment variable from
Microsoft Agent Framework, default off.
- Off (default): only metadata is recorded — sizes, counts, outcome, fault domain. The
*_bytesattributes above are emitted; the content attributes are not. - On: content attributes are attached (bounded in length):
af.agent.input,af.agent.response,af.dynamic_session.code/.stdout/.stderr,af.delegate.task/.result, plus MAF prompt/response/tool-arg content (viaenable_instrumentation(enable_sensitive_data=True)). - Never captured, regardless of the flag: secrets — MCP
Authorizationheaders/tokens, connection strings, and the ACA system key. Endpoints are reduced to host only. Theweb_requestspan never carries the full request URL (query string or userinfo stripped), request/response bodies, or header values — it is host/status/size metadata only, unaffected byENABLE_SENSITIVE_DATA.
Noise & cost control¶
Most Application Insights volume from a function app is not the runtime's spans — on a real app, runtime + MAF spans were only a few KB/run, while over 90% of ingestion was low-signal traces: Azure SDK HTTP request/response dumps, the Azure Monitor exporter's own "Transmission succeeded…" logs, credential chatter, and Functions host startup dumps.
Worker-side noise — handled for you. Regardless of whether telemetry export is active, the
runtime raises the log level of known-noisy third-party loggers (Azure SDK HTTP logging, the Azure
Monitor exporter, azure.identity, httpx, and OpenTelemetry internals) — but only when no level
is set directly on that logger, so a level you set directly on it is never overridden (a level set
on a parent/root logger is not consulted). See _NOISY_LOGGERS in _observability.py.
Host-side noise — set it in host.json. Host startup/options logging is emitted by the
Functions host, so quiet it with log-level overrides (or the equivalent
AzureFunctionsJobHost__logging__logLevel__<category> app settings):
{
"logging": {
"logLevel": {
"default": "Warning",
"Host.Startup": "Warning",
"Host.Function.Console": "Warning",
"Microsoft.Azure.WebJobs.Hosting.OptionsLoggingService": "Warning"
}
}
}
Other levers: keep ENABLE_SENSITIVE_DATA off (default); sample with
OTEL_TRACES_SAMPLER=parentbased_traceidratio + OTEL_TRACES_SAMPLER_ARG=0.1; set a daily cap /
retention on the workspace; and avoid double export — don't run both the worker exporter and
host worker-streaming (PYTHON_APPLICATIONINSIGHTS_ENABLE_TELEMETRY + telemetryMode) for the same
telemetry. The runtime now detects an existing OpenTelemetry provider and skips its own Azure
Monitor setup, so enabling the worker exporter path will not double-export — it is simply
unnecessary when the runtime is already handling exporter configuration.
Delegation-heavy apps: a single coordinator turn with several subagents: can fan out into many
child spans (coordinator + N specialists + MAF's own chat/invoke_agent children — see
Span execute_tool delegate_<slug>).
Azure Monitor's default rate-limited sampler counts spans, so a large fan-out can exhaust its budget
and drop whole traces under load — prefer the explicit OTEL_TRACES_SAMPLER=parentbased_traceidratio
+ OTEL_TRACES_SAMPLER_ARG setting above instead of relying on the default. Because that sampler is
trace-id-deterministic, a sampling decision applies consistently to the whole nested trace (no
half-traces), but logs on a dropped trace are dropped with it.
Quick KQL¶
// Everything that happened in one run, in order
union AppRequests, AppDependencies, AppTraces, AppExceptions
| where OperationId == "<operation-id>"
| project TimeGenerated, itemType, Name, Message, Success, DurationMs
| order by TimeGenerated asc
// Sandbox executions that actually failed (even if the tool "succeeded")
AppDependencies
| where Name == "dynamic_session.execute"
| extend stderr_present = tostring(Properties["af.dynamic_session.stderr_present"])
| where Success == false or stderr_present == "true"
| project TimeGenerated, OperationId, DurationMs, Properties
// Delegate calls that failed or timed out (recovered — the coordinator kept running)
AppDependencies
| where Name startswith "execute_tool delegate_"
| extend outcome = tostring(Properties["af.delegate.outcome"]), specialist = tostring(Properties["af.delegate.specialist"])
| where outcome in ("error", "timeout")
| project TimeGenerated, OperationId, specialist, outcome, DurationMs, Properties
Measuring telemetry volume (billed bytes per run)¶
Use these to size ingestion/cost before and after enabling observability. _BilledSize is the
billed bytes per item.
// Average billed volume per agent run, broken down by table.
// A "run" = an operation that contains an invoke_agent span for this app role.
let runs = AppDependencies
| where TimeGenerated > ago(30d)
| where AppRoleName == "func-agents-6q2arnxahkobm" // <-- your function app role name
| where Name startswith "invoke_agent "
| distinct OperationId;
union withsource=TableName AppRequests, AppDependencies, AppTraces, AppExceptions, AppMetrics
| where TimeGenerated > ago(30d)
| where OperationId in (runs)
| summarize items = count(), billedBytes = sum(_BilledSize) by OperationId, TableName
| summarize avgItemsPerRun = avg(items), avgBytesPerRun = avg(billedBytes) by TableName
| order by avgBytesPerRun desc
// Isolate THIS runtime's spans (agent.run + dynamic_session.execute) vs MAF gen_ai spans.
// Rerun after deploying an observability change to confirm the real before/after.
let runs = AppDependencies
| where TimeGenerated > ago(7d)
| where AppRoleName == "func-agents-6q2arnxahkobm"
| where Name startswith "invoke_agent "
| distinct OperationId;
AppDependencies
| where TimeGenerated > ago(7d)
| where OperationId in (runs)
| summarize
runtimeSpanItems = countif(Name == "dynamic_session.execute" or Name startswith "agent.run "),
runtimeSpanBytes = sumif(_BilledSize, Name == "dynamic_session.execute" or Name startswith "agent.run "),
mafItems = countif(Name startswith "invoke_agent " or Name startswith "chat " or Name startswith "execute_tool "),
mafBytes = sumif(_BilledSize, Name startswith "invoke_agent " or Name startswith "chat " or Name startswith "execute_tool ")
by OperationId
| summarize avgRuntimeSpanItems = avg(runtimeSpanItems), avgRuntimeSpanBytes = avg(runtimeSpanBytes),
avgMafItems = avg(mafItems), avgMafBytes = avg(mafBytes)
Appendix A — Optional app-level enhancements¶
These make monitoring easier for app authors but are not required — share them if someone wants richer dashboards. They build on the spans/metrics above.
- Tier 0 — Native portal (automatic). With telemetry on, Transaction Search, Application Map,
Failures, and the GenAI/Agents views work with no queries, because the runtime emits the
agent.runparent span plus MAFgen_aichildren. - Tier 1 — One
azd-provisioned Workbook. An Azure Monitor Workbook (Microsoft.Insights/workbooks) wired to the app's App Insights: runs over time, failure rate, tool failure rate, dynamic-session errors, token usage, and a failed-runs drilldown byoperation_Id. A reference module ships with the sample app atinfra/app/observability-workbook.bicep. - Tier 2 — Optional alerts (off by default). Bicep metric/scheduled-query alert rules for run failure-rate and dynamic-session error-rate.
- Tier 3 — Metrics charts (no KQL). Build portal charts from the
azure_functions_agents.*metrics by picking a metric and dimension. - Tier 4 — KQL pack. Copy-paste queries (see "Quick KQL" above) for deep dives.
Appendix B — Roadmap¶
Implemented (must-have): runtime-owned OTel bootstrap + Azure Monitor auto-configuration via
the optional [monitor] extra; fault-domain / lifecycle-stage conventions; sandbox truth-telling +
ACA correlation; the agent.run summary span; sensitive-data gating (default off); chat-time
sub-agent delegation span enrichment + error accounting (FRD 0007).
Follow-ups (good-to-have):
- Broader fleet metrics (per-agent run counts/duration, tokens, tool-call metrics).
- Full stage attribution for registration, model build, tool/MCP dropped-tool, storage, and response-validation failures (today's remaining silent gaps).
- Automatic per-tool span enrichment via the tool wrapper.
- The Tier-1/2 dashboards and alerts above, shipped as reusable infra.
- Delegation SSE passthrough (surface a specialist's internal stream deltas through the coordinator's stream) and cross-boundary token roll-up (FRD 0007 §4.12 accepted limitations).
Implementation map¶
| Area | Files |
|---|---|
| Bootstrap, conventions, helpers, metrics | src/azure_functions_agents/_observability.py |
| Bootstrap call site | src/azure_functions_agents/app.py |
Sensitive-data env handling (ENABLE_SENSITIVE_DATA) |
src/azure_functions_agents/_observability.py |
| Sandbox span + stderr surfacing + ACA correlation | src/azure_functions_agents/system_tools/sandbox.py |
web_request span, SSRF blocking, and truncation reporting |
src/azure_functions_agents/system_tools/web_request.py |
agent.run span + sensitive-log gating |
src/azure_functions_agents/registration/_handlers.py |
delegate_<slug> tool build, failure/cancellation adapter, span annotation (FRD 0007) |
src/azure_functions_agents/runner.py |
| Tests | tests/test_observability.py, tests/test_system_tools_sandbox.py, tests/test_web_request.py, tests/test_runner_delegation.py |