Chapter 05 · Platform

Observability

Two observability stacks, one OpenTelemetry pipeline. Whether you choose Azure Monitor or the Grafana OSS stack, the harness exports the same traces, metrics, and logs.

i
Authoritative blueprint

This page is the operator's view — how to wire each backend and what to look at first. The full instrumentation contract (span/metric/log names, GenAI semantic-convention mappings, resource attribute requirements, sampling policy) lives in documentation/blueprints/agentic-harness-observability.md (v1.3) and the single emit-point in Domain.AI/Telemetry/Conventions/GenAiSemconvRegistry.cs. When this page and the blueprint disagree, the blueprint wins.

!
GenAI semantic conventions are still evolving

The OpenTelemetry GenAI semantic conventions this harness emits are marked Development upstream and are gated behind the OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental opt-in. Attribute and span names under gen_ai.* may change in a future OTel release — pin your collector mappings and re-validate on SDK upgrades. The current pinned version is tracked in documentation/architecture/magentic-spans.md.

Two paths, same data

The harness uses OpenTelemetry (OTel) as its instrumentation standard. This means you choose where the telemetry goes — Azure-native or open-source — without changing any application code. The same spans, metrics, and log records flow regardless of the exporter.

Path 1 — Azure Native

App
OTel SDK
Application Insights
OTLP exporter
Azure Monitor
Log Analytics
Dashboards
Azure Workbooks

Path 2 — OSS Stack

App
OTel SDK
OTel Collector
gRPC receiver
Backends
Tempo + Prometheus + Loki
Dashboards
Grafana
i
Run both simultaneously

You can run both paths at the same time. The OTel SDK supports multiple exporters. Many teams send traces to Grafana/Tempo for developer debugging and metrics to Azure Monitor for operations/alerting.

What the harness instruments

The harness emits structured spans for every major subsystem. These spans carry semantic attributes that let you slice traces by model, tool, quality score, or token count.

Distributed traces (spans)

Span Source What It Tracks
invoke_agent Agent runtime Top-level agent turn: prompt to response (parents every child span below)
chat Chat client A single LLM chat-completion call, with model, token, and finish-reason attributes
execute_tool Tool executor Individual tool execution with args and result. MCP tools surface here too (wrapped as AITools)
embeddings Embedding service Embedding generation for RAG ingestion/queries, with token count
invoke_workflow Magentic orchestration Root multi-agent workflow span (manager + participant rounds hang off it)
invoke_a2a A2A client / server Agent-to-agent dispatch, correlated across caller and callee spans

Custom metrics (counters & histograms)

Metric Type What It Measures
agent.orchestration.turns_total Counter Total agent turns processed
agent.tokens.input / agent.tokens.output / agent.tokens.total Histogram Token usage per turn (input, output, and combined)
agent.tokens.cache_read / agent.tokens.cache_write Counter Prompt-cache token accounting (cache hits vs. cache writes)
rag.retrieval.duration Histogram RAG retrieval latency in ms
agent.tool.invocations Counter Tool invocations by tool name
agent.tool.duration Histogram Tool execution latency in ms, by tool
agent.governance.decisions / agent.governance.violations Counter Governance decisions evaluated and policy violations raised
agent.safety.blocks Counter Content-safety blocks by category
i
These are SDK-side names — the collector adds the prefix

The instrument names above are exactly what the app emits (no harness. or service-name prefix — that would double-prefix). The full authoritative list is locked in Tests/Presentation.AgentHub.Tests/Telemetry/Contracts/MetricNamingContract.cs. On the Prometheus path the OTel Collector applies namespace: agentic_harness (scripts/otel-collector/config.yaml), so agent.tokens.total scrapes as agentic_harness_agent_tokens_total. Never add the prefix in app code.

Azure Monitor setup

For the Azure-native path, configure the OTel exporter to send data to Application Insights via its connection string.

json
{
  "OpenTelemetry": {
    "Exporter": "AzureMonitor",
    "AzureMonitor": {
      "ConnectionString": "InstrumentationKey=xxx;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/"
    }
  }
}

Application Insights provides built-in dashboards for request rates, failure rates, and response times. For agent-specific views, create custom Azure Workbooks that query the customEvents and dependencies tables in Log Analytics.

KQL query example

Agent turn analysis — average and p95 duration by span name, bucketed by hour:

kql
dependencies
| where name in ("invoke_agent", "chat", "execute_tool", "embeddings")
| summarize avg(duration), percentile(duration, 95), count()
  by name, bin(timestamp, 1h)
| render timechart

Grafana + Tempo + Prometheus setup

For the OSS path, point the OTel exporter at your OTel Collector endpoint.

json
{
  "OpenTelemetry": {
    "Exporter": "Otlp",
    "Otlp": {
      "Endpoint": "http://otel-collector:4317",
      "Protocol": "grpc"
    }
  }
}

The OTel Collector receives spans, metrics, and logs, then routes them to Tempo (traces), Prometheus (metrics), and Loki (logs). Grafana provides the unified dashboard layer across all three backends. This stack runs as sidecar containers in the Container Apps environment or as a separate deployment.

i
App emits OTLP + Prometheus; collector is optional

The harness itself only knows how to speak two protocols: OTLP (over gRPC) and Prometheus scrape (the /metrics endpoint, mapped in Program.cs). The OTel Collector shown in the diagram is one common packaging choice โ€” it lets you fan out to Tempo, Loki, and other backends without changing app config. You can also point the OTLP exporter directly at a backend that accepts OTLP (Tempo, Honeycomb, Datadog) and skip the collector entirely. The app doesn't know or care.

i
Dashboards are bring-your-own

There are no pre-bundled Grafana dashboard JSON files in this template โ€” the metric and span names listed above are the contract you build dashboards against. The shape of every span and metric attribute lives in Domain.AI/Telemetry/Conventions/GenAiSemconvRegistry.cs, and the metric names are locked in by Tests/Presentation.AgentHub.Tests/Telemetry/Contracts/MetricNamingContract.cs (asserted by DashboardContractTests.cs), so dashboard authors can rely on the names staying stable across releases.

Trace sampling — do it at the collector

High-traffic production traces are expensive to store. You keep the ones that matter (errors, slow requests, agent runs) and sample down the routine ones. The right place to make that decision is tail-based sampling at the OpenTelemetry Collector, not in application code.

!
Why not in the app?

The harness deliberately ships no in-app tail sampler. An earlier version had one (a custom OTel BaseProcessor<Activity>), but it could not actually drop spans: by the time a processor's OnEnd runs, the OTLP and Azure Monitor exporters have already enqueued the span for export. It exported 100% of spans regardless of the configured rate. Tail sampling needs to buffer whole traces across process boundaries and decide after the trace completes — that is exactly what the Collector's tail_sampling processor is built for. The SDK exports every span; the collector decides what to keep.

Add a tail_sampling processor to your collector pipeline (scripts/otel-collector/config.yaml). The policy below reproduces the intent of the removed in-app sampler: always keep error traces, traces slower than 5s, and agent-execution traces; probabilistically sample ~10% of everything else. Policies are OR-combined — a trace matching any keep policy is retained.

yaml
processors:
  tail_sampling:
    # Buffer a trace this long after its last span before deciding.
    decision_wait: 10s
    # Cap on traces held in memory at once (tune for your throughput).
    num_traces: 50000
    expected_new_traces_per_second: 200
    policies:
      # 1. Always keep error traces.
      - name: keep-errors
        type: status_code
        status_code:
          status_codes: [ERROR]

      # 2. Always keep slow traces (> 5s end-to-end).
      - name: keep-slow
        type: latency
        latency:
          threshold_ms: 5000

      # 3. Always keep agent-execution traces (gen_ai.system set by the harness).
      - name: keep-agent-by-genai-system
        type: string_attribute
        string_attribute:
          key: gen_ai.system
          values: [az.ai.agent, microsoft.extensions.ai, semantic_kernel]
          enabled_regex_matching: false

      # 3b. Also keep any trace that carries the agent.phase attribute.
      - name: keep-agent-by-phase
        type: string_attribute
        string_attribute:
          key: agent.phase
          values: ['.+']
          enabled_regex_matching: true

      # 4. Sample ~10% of everything that matched none of the above.
      - name: sample-the-rest
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp/tempo]   # or your backend of choice
i
Keep the SDK exporting everything

For tail sampling to work, every span for a trace must reach the collector, so keep the app-side sampler at AlwaysOn (dev) or ParentBased(TraceIdRatioBased(1.0)) (prod) and let the collector do the thinning. If you run more than one collector instance, put a loadbalancing exporter in front so all spans of a trace land on the same collector — tail_sampling buffers per-collector and cannot see spans routed elsewhere. The full sampling rationale lives in documentation/blueprints/agentic-harness-observability.md (gate G6).

Tracing an agent conversation

When you open a trace in your viewer (Jaeger, Tempo, or Application Insights Transaction search), here is what you see for a typical agent turn that involves RAG retrieval and tool use:

invoke_agent [1,240ms]
Root span. Total duration of the conversation turn. Carries gen_ai.agent.name, gen_ai.conversation.id, and (on the child chat spans) gen_ai.request.model.
chat [520ms]
Child span. The LLM chat-completion call. Carries gen_ai.request.model, gen_ai.usage.input_tokens, and gen_ai.usage.output_tokens.
embeddings [180ms]
Child span. Query embedding for RAG retrieval. Carries gen_ai.request.model and gen_ai.usage.input_tokens.
execute_tool [file_system] [95ms]
Child span. The agent called the file_system tool. Attributes include gen_ai.tool.name, gen_ai.tool.call.arguments, and gen_ai.tool.call.result.
execute_tool [ext-mcp-tool] [210ms]
Child span. An external MCP tool was invoked. MCP tools are wrapped as AITools, so they surface as ordinary execute_tool spans carrying the same gen_ai.tool.* attributes.

Each span carries attributes like gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.tool.name, and error.type that let you diagnose exactly where time and tokens went.

!
Token tracking matters for cost

The agent.tokens.total histogram, combined with the agent.tokens.cache_read / agent.tokens.cache_write prompt-cache counters and Azure OpenAI pricing, gives you a direct cost-per-conversation metric. Set alerting thresholds based on your budget to catch runaway agent loops before they burn through your token allocation.

Alerting

Recommended alert rules for production deployments. These apply to both Azure Monitor (metric alerts) and Grafana (Prometheus alert rules).

Alert Condition Action
Agent error rate > 5% invoke_agent spans with the error.type attribute set Page on-call
RAG latency > 3s p95 rag.retrieval.duration histogram p95 Scale AI Search replicas
Governance violations spike agent.governance.violations counter spike Check policy config and provider health
Token spend > daily budget agent.tokens.total histogram sum Throttle non-critical requests