Skip to content
Tracon

Observability

Tracon exposes four complementary views of a running system:

  1. Run records and events preserve the product-level history.
  2. OpenTelemetry traces and metrics feed your existing observability backend.
  3. Persisted run traces make one run inspectable through the console and API.
  4. Health and diagnostics explain whether storage, migrations, and providers are ready.
flowchart LR
    accTitle: Where each observability signal comes from and where it goes
    accDescr: One run emits events, spans, and measurements. Events go to the run store that the console and replay read. Spans go both to your OpenTelemetry exporter and, when sampling selects them, to the internal trace store. Measurements go to the meter. Health checks read storage and providers separately.
    RUN["One run"] --> EV["Run events<br/>RunRecording"]
    RUN --> SPAN["Spans<br/>tracon.run"]
    RUN --> MET["Measurements<br/>meter Tracon"]
    EV --> STORE[("Run store")]
    STORE --> UI["Console · replay · tool history"]
    SPAN --> OTLP["Your OpenTelemetry exporter"]
    SPAN --> SAMP{"Observability<br/>sampling"}
    SAMP -->|kept| TRACE[("Trace store<br/>per-run inspection")]
    MET --> OTLP
    HEALTH["Health checks"] --> STORE
    HEALTH --> PROV["Provider health"]

These views have separate switches and retention concerns. Turning down event recording does not configure your OpenTelemetry exporter, and disabling trace persistence does not stop an exporter from receiving spans.

Tracon does not choose an exporter. Add its stable source and meter to the OpenTelemetry pipeline that your application owns:

using Tracon;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSource(TraconDiagnostics.ActivitySourceName)
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddMeter(TraconDiagnostics.MeterName)
.AddOtlpExporter());

Set the OTLP address, headers, protocol, sampling, and resource attributes through the OpenTelemetry packages and configuration used by your application. Tracon’s activity source and meter are both named Tracon. The root run span is tracon.run.

Instrument Meaning
tracon.runs Completed run count by status
tracon.run.duration Run duration in seconds
tracon.tokens Reported input and output token count
tracon.tool.invocations Tool invocation count
tracon.tool.duration Tool duration in seconds
tracon.run.cost Calculated token cost of a run when a price is known. What a run reported beyond tokens is not in it
tracon.quota.usage Current quota usage gauge when enabled
tracon.quota.limit Current quota limit gauge when enabled
tracon.judge.cost Cost reported for evaluation judges
tracon.judge.score Judge score distribution
tracon.model.cache Response-cache lookups, tagged hit or miss
tracon.agent_source.failures Agent-source failure and contract-violation count
tracon.job.executions Background jobs that reached a terminal status
tracon.job.duration Duration of a single background-job attempt, in seconds
tracon.job.queue.depth Outstanding jobs per lane and open status, when enabled
tracon.audit.write_failures Audit entries that could not be written, tagged by outcome
tracon.run.recording_failures Run records that could not be written, tagged by the stage they were lost at

These are the tag keys the instruments and the run span carry. They are the names you group and filter by in a dashboard or an alert rule, so they are as stable as the metric names above.

Attribute Carried by Value
tracon.agent.name Every run signal The agent’s registered name
tracon.agent.version Run signals for a versioned definition The definition version that ran
tracon.run.status tracon.runs, run span Completed, Failed, Canceled, and the other run statuses
tracon.run.streaming Run signals Whether the caller asked for a stream
tracon.model.id Run and cost signals The model the run was bound to
tracon.tenant.id Run spans, and the quota gauges. On the other metrics only when IncludeTenantTag is true (see the tenant tag) The resolved tenant
tracon.tool.name tracon.tool.invocations, tracon.tool.duration The invoked tool
tracon.token.direction tracon.tokens input or output, and nothing else
tracon.cost.currency tracon.run.cost The currency the configured price is expressed in
tracon.quota.scope · tracon.quota.period · tracon.quota.metric Quota gauges Which quota the gauge reports
tracon.judge.name tracon.judge.cost, tracon.judge.score The judge that produced the score
tracon.model.provider tracon.model.cache The model provider name
tracon.model.cache.result tracon.model.cache hit or miss
tracon.skill.name Skill signals The loaded skill
tracon.script.name · tracon.script.exit_code · tracon.script.duration_ms Skill-script span The script, how it ended, and how long it took
tracon.script.denial_reason Skill-script span Present only when a gate stopped the run before it started: which gate, and why. The span’s status is Error and it carries no exit code or duration, because no process ran
tracon.compaction.input_tokens · tracon.compaction.output_tokens Compaction span What the summarization call itself cost
tracon.agent_source.name tracon.agent_source.failures The failing IAgentSource’s name
tracon.agent_source.operation tracon.agent_source.failures list, resolve, or consistency
tracon.job.lane Every job signal The lane the job was queued in
tracon.job.handler_key tracon.job.executions, tracon.job.duration The key of the handler that ran the job
tracon.job.status Every job signal A terminal status on the counter and the histogram; an open one on the gauge
tracon.audit.action tracon.audit.write_failures The action that could not be recorded, such as approval.decision
tracon.audit.outcome tracon.audit.write_failures swallowed if the operation continued, refused if it was stopped
tracon.recording.stage tracon.run.recording_failures Which write was lost: start, event, tool_invocation, non_token_usage, completion, sink, or input

Three spans and one tool name are not metrics at all, and are named here because a trace search needs them: execute_skill_script (a skill script’s own span, carrying the three tracon.script.* attributes), compact_history (the summarization call, carrying the two tracon.compaction.* attributes), and the tool name skill_script, which is what a script-backed skill appears as in tracon.tool.invocations.

tracon.tenant.id has three different states, because the three kinds of signal pay a different price for it:

Signal Carries tracon.tenant.id
Run spans Always. A trace is not aggregated, so a tag adds no series.
tracon.quota.usage and tracon.quota.limit Always. A quota is a fact about one tenant. A gauge without the tenant says nothing.
tracon.runs, tracon.run.cost, tracon.judge.cost, tracon.judge.score, tracon.model.cache, tracon.audit.write_failures, tracon.run.recording_failures, tracon.job.executions Only when Tracon:Observability:IncludeTenantTag is true. Off by default.

The tag multiplies every series of those eight metrics by the number of tenants. A metric backend slows down and bills by the series. Turn the tag on when the number of tenants is small and bounded, and a dashboard or an alert must split a metric by tenant. With the tag off, the series of all tenants add up, so a chart shows the total.

{
"Tracon": {
"Observability": {
"IncludeTenantTag": true
}
}
}

The value must be true or false. Any other text stops the application at startup.

tracon.audit.write_failures counts audit entries that could not be written. Until it existed, a broken audit store was visible only to whoever went looking through logs — which is the wrong way to find out that your evidence has holes in it. Any non-zero value deserves an alert: unlike a failed model call, nothing retries this and nothing else reports it.

The tracon.audit.outcome tag says what the failure cost you, and the two answers are very different:

  • swallowed — the operation went ahead without its record. You have a gap in the audit trail and a working system.
  • refused — the operation did not happen. This is one of the eight fail-closed operations in What is guaranteed to be written, and your callers are seeing errors.

tracon.audit.action names the action, such as approval.decision or script.run. The affected entity is deliberately not a tag: an entity name is your data, so tagging it would give every agent, trigger and session a time series of its own. Use GET /api/audit to find the specific records.

Run recording is best-effort and stays that way: if the store refuses a write, the run keeps going and answers your caller. That is a deliberate choice — by the time a write fails, the model has already been called and the tools have already had their effects, so failing the run would buy you nothing and cost you an answer you already paid for.

What changed is that the loss is no longer silent. tracon.run.recording_failures counts write attempts whose record was lost. Any non-zero value means a run happened whose evidence is incomplete or missing, and nothing retries it.

On the store side, that count is in practice the number of runs that lost their record. This is not a coincidence: the writer gives up after its first store failure and stops attempting, so one broken run contributes one measurement no matter how long it ran or how many events it would have written.

tracon.recording.stage says what was lost, and the stages are not interchangeable:

  • start — the run record was never opened. Nothing about this run is queryable.
  • event — the run exists but its event stream stops partway. Replay and the run timeline are incomplete.
  • tool_invocation — a tool’s measurement was lost. The run and its events survive; tool analytics under-report.
  • non_token_usage — an item a workflow function node reported with ReportUsageAsync could not be written, so it is missing from GET /api/runs/{runId}/usage. The run’s call-tree budget already counted the amount before the write. Recording of the run stops there, as after any other lost write, so the run’s own nonTokenCost total is not stored. Its quota entry and webhook still count what its nodes reported.
  • completion — the run never reached a terminal status in the store, so it looks unfinished to anything that queries it.
  • sink — a registered IRunEventSink threw and was dropped for that run. The store is unaffected; your own downstream consumer missed events. Counted once per sink per run, and the failing sink’s type is named in the accompanying log entry.
  • input — the run’s input messages were not saved. The run and its record are whole; only replay is impossible for it.

A sink failure and a start failure are the two ends of the range: the first costs you a downstream copy, the second costs you the run’s entire record. Alert on both, but do not page the same person for them.

The run identity is deliberately not a tag here, for the same reason it is not one anywhere else: it is unbounded. It is in the log entry that accompanies every one of these measurements.

tracon.job.executions counts jobs that finished. Only a terminal status reaches it — Completed, Failed, or Cancelled. When an attempt fails and the job is released for another try, nothing is counted; a job configured with three attempts that ultimately fails is counted once, not three times.

tracon.job.duration follows the same rule and measures one attempt, not the job’s whole lifetime. For a job that failed twice before succeeding, the single recorded measurement covers the last attempt. It is taken from a monotonic clock, so a clock correction during the job cannot produce a negative duration.

tracon.job.queue.depth is off by default, because it reads the database. Turn it on with Observability:EnableJobQueueDepthGauge: a background refresh then reads the depth once at startup and again every Observability:JobQueueDepthRefreshInterval, and a scrape only reads the last result. A scrape never waits for the database, so a reported value can be up to one interval old, and the first scrape after startup can be empty. It reports only the open statuses — Pending, Leased, and Running — so its cost tracks the work still outstanding rather than the queue’s whole history. A lane with no open jobs is absent rather than reported as zero. The gauge carries no tenant tag: the worker pool leases across every tenant, so queue depth is a signal about the pool, not about one tenant’s work.

Token and cost data depend on the provider response. Missing usage remains unknown; it is not converted to zero. Tracon also does not invent a price. A run can have token metrics but no cost metric when neither the model catalog nor your pricing configuration supplies a price.

That is the honest answer, not a useful one, so the host says so at startup: a Warning names every provider/model pair no price source covers, and GET /api/diagnostics reports the same list under pricing along with how many models do resolve to a price. A catalog with no prices at all records every run with empty cost columns and PricingSource.Unknown, which reads exactly like a cost report that is broken. Set the rate on the catalog entry (InputCostPerMillionTokens / OutputCostPerMillionTokens) or under Tracon:Pricing:Providers, then backfill the runs you already have with POST /api/stats/recalculate-costs. Tracon ships no built-in price table on purpose: rates change far faster than a NuGet package is released.

A run’s cost is a price snapshot (concepts/runs): computed once, from the unit price in effect when the run ended, and never recomputed from a later price change. POST /api/stats/recalculate-costs is a repair tool for the runs that were unpriced at the time — it fills in a price for them once one becomes available, and never touches a run that already has a known cost.

tracon.tokens reports only two direction values, input and output. The finer counters a provider may report — prompt-cache hits, reasoning tokens, audio tokens — are counted inside those two totals and are recorded on the runs row rather than emitted as extra metric series, for the same reason: adding them would double count every token on the dashboard.

Where the tokens actually went is a query:

Terminal window
curl -s "http://localhost:5081/tracon/api/runs?take=1" | jq '.[0].usage'
{
"inputTokens": 12480,
"outputTokens": 310,
"totalTokens": 12790,
"cachedInputTokens": 11900,
"reasoningTokens": 128
}

A counter the provider never reported stays null, not 0. The distinction is load-bearing: 0 claims a measured cache miss, null says nothing was measured — and a report that cannot tell them apart will show a confident 0% cache hit rate for every provider that stays silent.

The internal trace store keeps a sampled copy for run-level inspection:

{
"Tracon": {
"Observability": {
"Enabled": true,
"PersistSpans": true,
"SuccessSampleRatio": 0.1,
"AlwaysPersistFailures": true,
"MaxSpansPerRun": 200,
"RecordSensitiveData": false,
"IncludeAgentVersionTag": true,
"IncludeTenantTag": false,
"EnableQuotaUsageGauge": false,
"QuotaUsageRefreshInterval": "00:00:30"
}
}
}

Successful traces are sampled when the run finishes because only then is the result known. Failed traces bypass the success sample when AlwaysPersistFailures=true. Spans remain in memory until that decision, bounded by MaxSpansPerRun. Excess spans are dropped and one warning is logged for the run.

Read a retained trace with the root run ID:

Terminal window
curl -sS \
-H "Authorization: Bearer $TRACON_API_KEY" \
https://agents.example.com/tracon/api/runs/$RUN_ID/trace

Child spans are collected into the root run’s tree. A 404 can mean that the run was not sampled, trace persistence was off, the trace was removed, or that no trace was written. It does not by itself mean the run never existed.

Other useful views are GET /api/runs/{runId}/tools and GET /api/tools/usage. The usage endpoint defaults to 50 tools and clamps its maxTools query value to the range 1 through 200.

Setting Default Effect
Observability.Enabled true Controls the MAF telemetry decorator and internal span collector; run recording remains separate
PersistSpans true Writes sampled spans to the Tracon trace store
SuccessSampleRatio 0.1 0 persists no successful trace; 1 persists all successful traces
AlwaysPersistFailures true Keeps failed traces independent of the success sample
MaxSpansPerRun 200 Bounds the in-memory trace buffer; excess spans are dropped
RecordSensitiveData false Excludes prompt, message, and completion text from span tags
IncludeAgentVersionTag true Adds agent version to run spans and run metrics
IncludeTenantTag false Adds the tenant to the run, cost, judge, cache, audit, recording and job metrics. Spans and the quota gauges always carry it
EnableQuotaUsageGauge false Avoids background database reads until explicitly enabled
QuotaUsageRefreshInterval 30 seconds How often a background refresh reads the quota gauge samples; a collection only reads the last result and never waits for the database

PersistSpans=false only disables the internal store. Spans can still reach the consumer’s configured exporter.

Observability.Enabled is not a global exporter switch. The run-recording wrapper owns the root run span and stable run metrics. Your OpenTelemetry source, meter, and sampler configuration still decides what the consumer pipeline collects.

Run recording is the durable source for the console, event replay, tool history, and run inspection:

{
"Tracon": {
"RunRecording": {
"Enabled": true,
"RecordMessageDeltas": true,
"RecordToolPayloads": true,
"RecordReasoningDeltas": false,
"MaxPayloadLength": 8192,
"RecordRunInput": true
}
}
}
Setting Default Limit or consequence
RunRecording.Enabled true When false, no run event is written
RecordMessageDeltas true Turning it off reduces write volume for streaming runs
RecordToolPayloads true Tool arguments and results can contain personal or secret data
RecordReasoningDeltas false Off unlike the other flags: reasoning output can run far longer than the answer and can restate input the answer never shows
MaxPayloadLength 8,192 characters Valid range is 0 through 1,048,576; 0 means no truncation
RecordRunInput true Required for run replay; input is not truncated by MaxPayloadLength

Event-store failures are logged and then event writes stop for that run. The agent run continues. A run-input write failure also leaves execution intact, but that run cannot be replayed. This failure isolation prevents an observability outage from becoming an agent outage. A registered IRunEventSink is held to the same rule: a sink failure never stops the store write, and a store failure never stops a sink from seeing the rest of the run.

RecordSensitiveData=false does not redact run events, tool payloads, or saved run input. It applies to span tags. Configure both sections and retention according to the data that your agents process.

Register Tracon in ASP.NET Core health checks, then choose the route yourself:

using Microsoft.AspNetCore.Diagnostics.HealthChecks;
builder.Services.AddHealthChecks()
.AddTraconHealthChecks(tags: ["ready"]);
var app = builder.Build();
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("ready"),
});

The check performs a light storage connection probe. It does not spend model tokens. It reads the cached model-provider health view:

State Meaning
Healthy Storage is reachable, migrations are current, and no provider is known to be broken
Degraded Storage works, but a provider circuit is open, more than one persistence provider is registered, no provider is registered at all, or every provider that was probed came back unhealthy
Unhealthy Storage is unreachable or migrations are pending

A provider that has not been probed yet is not a degradation, and the description says so: Healthy. No model provider has been probed yet. The status reflects only what is known to be wrong, so a correct installation reads green from its first request and a real outage stands out against it.

Provider probes call the provider’s model-list health surface, not a completion. A provider that has no health implementation reports Unknown. Read the cache with GET /api/models/health; add ?refresh=true to request a fresh probe.

Health results are cached for 60 seconds by default. No background refresh timer runs unless Tracon:Health:BackgroundInterval is set. Nothing else fills the cache — a completion through the provider does not — so without one of those two the readiness description keeps saying that nothing has been probed.

The setup report is not mapped by default:

app.MapTracon("/tracon", options =>
{
options.EnableDiagnosticsEndpoint = true;
options.RequireRolePolicies = true;
});

GET /api/diagnostics reports selected storage, migration state, provider setup, whether expected configuration keys resolve, and the run recording settings in force. It never returns secret values. It performs a light SQL probe and does not run a migration or call a model. The endpoint requires the Tracon Admin role when role policies are registered.

The runRecording block is there for one question in particular: an event that was never recorded looks exactly like one the model never produced. RecordReasoningDeltas is off by default, and while it is off a model that returns reasoning content streams it to the caller and records nothing, with no warning anywhere. Reading the effective value is the difference between “the model did not think”, “recording is off” and “something is broken” — a distinction that otherwise costs real provider calls to establish.

Symptom Check
No Tracon telemetry reaches the backend Add both TraconDiagnostics.ActivitySourceName and MeterName; then verify the consumer exporter, endpoint, and sampler
A run exists but /trace returns 404 Check PersistSpans, success sampling, failure override, retention, and that you used the root run ID
A trace is incomplete Look for the one-per-run span-limit warning and raise MaxSpansPerRun only after checking memory cost
Run cost is absent Confirm that the provider reported tokens and that the exact provider/model has a catalog or configured price
Replay says input is unavailable Keep RecordRunInput=true; inspect logs for an input-store failure or retention deletion
The run succeeded but events stopped Inspect the first run-store write error; Tracon disables later event writes for that run so execution can continue
A reasoning model’s thinking never appears in recorded events Set RunRecording.RecordReasoningDeltas = true; the live stream shows it either way, only recording is gated
A registered IRunEventSink stops receiving events partway through a run Check the warning log for that sink’s exception; it is disabled for the rest of that run only, other sinks and the store are unaffected
Readiness says no provider has been probed Nothing probes on its own: call /api/models/health?refresh=true or set Tracon:Health:BackgroundInterval
Health is Unhealthy after deployment Check database reachability and pending migrations before investigating providers
Quota gauges never appear Enable Observability.EnableQuotaUsageGauge and confirm the meter is collected; the first collection after startup can be empty until the first background refresh completes