Skip to content
OpenSmartRoute

Tracing and observability

Spans and events for every routing stage; memory, metrics, log, file and OpenTelemetry sinks; /events, /trace and /metrics.

docs/OBSERVABILITY.md

Every stage of a request - signal extraction, policy filtering, ranking, planning, execution, learning, health, cache, guard, shadow rollout, fair share and the autopilot - emits spans and events into a Tracer. Sinks decide what happens to them: keep the last N in memory for /trace, aggregate them into Prometheus counters, log them as JSON lines, append them to a file or forward them to OpenTelemetry. Instrumentation is always on and free when no sink is attached (a shared no-op span, no allocation), so the SDK stays zero-dependency and the cost of capturing everything is opt-in.

from opensmartroute import Router, configure_tracing
from opensmartroute.observability import MemorySink, MetricsSink

tracer = configure_tracing(MemorySink(), MetricsSink())      # process-wide; or configure_tracing() -> settings
router = Router(reg)                                          # picks up the global tracer
d = router.route("Prove that sqrt(2) is irrational")
for e in tracer.find(MemorySink).trace(d.request_id):        # spans + events of that request, in time order
    print(e["kind"], e["name"], e.get("duration_ms"), e["attributes"])
print(tracer.find(MetricsSink).prometheus())                 # osr_* exposition text
span  route            2.31ms {'text_sha256': '9f2c…', 'text_len': 35, 'target': 'large', 'confidence': 0.82, 'elapsed_ms': 2.1, ...}
event route.signals          {'duration_ms': 0.4, 'complexity': 0.71, 'domains': ['math'], 'reasoning_need': 0.9, 'contains_pii': False, ...}
event route.policy           {'pool': 2, 'admissible': 2, 'rejections': {}}
event route.rank             {'role': None, 'candidates': 2, 'strategies': {'capability': 0.05, ...}, 'top': [{'id': 'large', 'utility': 0.82, ...}]}

osr route "text" --events prints the same trace after the decision; osr serve exposes it over HTTP and the hosted platform shows it per workspace in the dashboard (activity drawer, /platform/dashboard/events, playground Trace tab - see the platform endpoints below).

What is captured#

SpanOpened byAttributes (end of span)
requestEnterpriseRouter.routemiddleware (class names), tenant, target, confidence, audited
http.requestosr serve middlewaremethod, path, app, status_code, target, inbound traceparent
routeRouter.routetext_sha256, text_len, tenant, objective, kinds, candidates, exclude, plan, task_id, session_id, target, kind, confidence, elapsed_ms, alternatives, abstain, plan_slots
planRouter._build_plan (inside route)primary, kind, slots
executeexecution.execute / aexecutetarget, kind, plan_slots, task_id, runner, ok, latency_ms, cost_usd, tokens, outcomes, system_prompt_len, pii_redacted, pii_restored
autopilot.cycleAutopilot.run_oncereason, online, accepted, outcome, cycle
EventEmitted when
route.signalssignals extracted: duration_ms, complexity, domains, actions, language, token estimate, PII, jailbreak risk, tools, task type, reasoning need
route.policyhard constraints applied: pool, admissible, rejections (target -> reason)
route.no_routeno admissible target (before NoRouteError is raised)
route.pinneda rule pinned a target (kept tells whether it survived the policy)
route.narrowedthe retriever shortlisted a large catalogue
route.shortlista strategy shortlisted candidates
route.rankstrategies scored (strategies with per-strategy ms, top 3, excluded_by_floor)
route.escalatethe LLM judge was consulted because confidence was low
route.explorethe bandit chose to explore (served tells whether the exploration was taken)
route.abstainthe calibrated confidence was below the abstention threshold
route.fallbacka fallback target replaced an unavailable one
plan.slota plan slot was filled (or not: filled=False, reason)
execute.stepone execution step (persona, skill, primary) finished
learn.outcomeRouter.learn / learn_correction recorded an outcome (corrected, learner)
learn.task_credittask-level credit assigned to the plan's targets
learn.calibratethe confidence calibrator was refit (temperature)
learn.improvea SelfImprover cycle finished (rows, champion_accuracy, challenger_accuracy, accepted, reason; inside an autopilot.cycle span when the autopilot ran it)
learn.promotea challenger SLM replaced the serving champion (rows, trained_at, targets, saved)
health.breakera circuit breaker changed state (previous, state; level warning when it opens)
cache.hit / cache.missCacheMiddleware lookup (target, age_s on a hit)
tenant.rejectedTenantMiddleware refused a request (reason, tenant)
route.slowTimeoutMiddleware saw a decision exceed its budget
guard.blocked / guard.flagged / guard.redactedGuardMiddleware verdicts (reasons, gadget, entities)
shadow.compare / shadow.verdictShadowMiddleware compared production and candidate; the A/B test reached a verdict
fairshare.throttled / fairshare.cappedFairShareMiddleware delayed or refused a tenant
autopilot.driftthe autopilot detected quality drift and scheduled a cycle

opensmartroute.observability.SPAN_NAMES and EVENT_NAMES list the vocabulary; a test asserts every emitted name belongs to it.

Privacy. Request text never enters a span or event: the route span carries text_sha256 (16 hex characters) and text_len (text_digest()); attribute strings are truncated to OSR_OBSERVABILITY_ATTRIBUTE_MAX_LEN (500) and nested values are JSON-cleaned. Sinks receive ids, numbers, target ids, reasons and scores.

Spans, events, trace ids#

An Event is a flat record: name, kind (event or span), ts, trace_id, span_id, parent_id, request_id, level, status and duration_ms (spans only) and attributes. A span's closing record is the same shape as an event so every sink handles one type.

The trace id of a request is derived once, at the root span: an inbound W3C traceparent wins, otherwise a UUID request id is reused, otherwise the request id is hashed (so a request_id supplied by the caller yields a stable trace id). Nested spans inherit trace id, request id and the sampling decision through a contextvars context, so threads and asyncio tasks spawned by the router carry the right parent. Tracer.current() returns the innermost open span; current_tracer() resolves the tracer to report to (the open span's, else one bound with use_tracer, else the global one), which is how middleware, the health registry and the executor emit without holding a reference.

Sinks#

SinkPurpose
MemorySink(max_events=1000)ring buffer; events(request_id=, trace_id=, name=, kind=, level=, limit=) (name may end with *), trace(request_id); backs /events, /trace/{id} and osr route --events
MetricsSink()counters and p50/p95/p99 per span name; snapshot() for /stats, prometheus() for /metrics (osr_events_total, osr_spans_total, osr_span_duration_ms, osr_span_decisions_total, osr_executions_total, osr_execution_cost_usd_total, osr_learn_outcomes_total, osr_breaker_transitions_total, osr_cache_events_total, osr_span_errors_total)
LoggingSink(log=None)one JSON line per event on the opensmartroute.events logger, level follows the event
FileSink(path)append-only JSONL, one event per line (ship with any log forwarder)
OpenTelemetrySink() (adapters.optional, otel extra)live bridge: each span becomes an OTel span (parented through the OTel context, joined to traceparent), each event a span event and an osr.events counter, durations an osr.span_duration_ms histogram; the host configures the SDK, exporter and resource

Write your own by subclassing EventSink and implementing emit(event) (optionally span_start, span_end, snapshot). A sink that raises is counted in Tracer.sink_errors and logged (first ten occurrences); it never breaks routing.

Configuration#

configure_tracing(*sinks, sample_rate=None) installs sinks on the process-wide tracer that every Router uses by default. With no sinks it reads ObservabilitySettings (OSR_OBSERVABILITY_*):

SettingEnvDefaultEffect
metricsOSR_OBSERVABILITY_METRICStrueattach a MetricsSink
memory_eventsOSR_OBSERVABILITY_MEMORY_EVENTS1000attach a MemorySink of that size (0 disables)
log_eventsOSR_OBSERVABILITY_LOG_EVENTSfalseattach a LoggingSink
events_fileOSR_OBSERVABILITY_EVENTS_FILE""attach a FileSink at that path
otelOSR_OBSERVABILITY_OTELfalseattach an OpenTelemetrySink (needs the otel extra)
sample_rateOSR_OBSERVABILITY_SAMPLE_RATE1.0share of root spans that are recorded
attribute_max_lenOSR_OBSERVABILITY_ATTRIBUTE_MAX_LEN500longest attribute string kept

Router(reg, tracer=Tracer([...])) gives one router a private tracer; RouterBuilder(...).with_tracing(*sinks_or_tracer, sample_rate=) does the same for an EnterpriseRouter (no arguments: build from settings). osr, osr serve, the Docker image and the hosted platform call configure_tracing() / Tracer.from_settings() at start-up, so the environment variables above are all that is needed in a container.

HTTP endpoints (osr serve)#

EndpointReturns
GET /metricsMetricsTelemetry exposition followed by the tracer's MetricsSink counters (names are disjoint)
GET /events?request_id=&trace_id=&name=route.*&kind=&level=&limit=200recent events from the memory buffer (404 when OSR_OBSERVABILITY_MEMORY_EVENTS=0)
GET /trace/{request_id}every span and event of one request, in time order, with its trace_id
GET /statsadds an observability block: sinks, sample rate, sink errors and each sink's snapshot

Every traced response carries X-OSR-Trace-Id; a client that sends traceparent gets its own trace id back, so the routing spans line up with the caller's trace in Jaeger, Tempo, Azure Monitor or any OTel backend. Probes (/healthz, /readyz), /metrics, /events, /trace/*, /whoami and the OpenAPI pages are never traced. The endpoints follow the server's access control: with OSR_SERVER_AUTH_TOKENS set, /events, /trace/* and /stats need a token (/metrics stays open for scrapers).

HTTP endpoints (hosted platform)#

The platform API (platform/api) exposes the same tracer per workspace, so a tenant only ever sees its own requests - and it is the platform's own observability: it persists every span and event to its database (osr_platform.telemetry, OSR_PLATFORM_TELEMETRY_STORE, on by default, off the request path through a queued writer; OSR_PLATFORM_TELEMETRY_RETENTION_DAYS), keeps per-minute HTTP counters for a day, computes time series from its usage records and evaluates alert rules in-process (osr_platform.alerts). Nothing has to be exported to a metrics, tracing or alerting system.

EndpointReturns
GET /api/v1/trace/{request_id}the activity row of the request plus its spans and events (from the buffer, or from the store once evicted - source says which), the feedback reported for it (outcomes) and its audit records (audit); 404 for another workspace's request
GET /api/v1/events?name=&kind=&level=&request_id=&since=&until=&limit=events joined to the workspace through its request ids (deployment-wide events on plans with stats); since / until select a time range from the store (default the last 6 hours)
GET /api/v1/telemetry/series?window=the workspace's requests, failures, p50 / p95 latency, cost and tokens per bucket (1h .. 30d) with per-target and per-endpoint totals; http (plans with stats) adds the deployment's requests, 4xx / 5xx and latency percentiles per bucket
GET /api/v1/alertsactive conditions - budgets, failing requests, and on plans with stats readiness, 5xx rate, latency SLO, breakers, drift, autopilot errors, a stale SLM, tracing off - each with the dashboard page that shows or fixes it
GET /api/v1/statuspublic readiness with the tracing state, for status pages (503 while a check fails)
GET /api/v1/activityrequest log with traced: true on rows whose spans are kept and outcome summarising the feedback per request

POST /api/v1/route returns trace_id and X-OSR-Trace-Id. The dashboard renders the history at /platform/dashboard/events (1 hour to 7 days), a span waterfall from every traced activity row and a Trace tab in the playground; the overview shows the 24-hour series and the alerts, /platform/dashboard/health the deployment series, the Systems card (/api/v1/status) and the same alerts.

OpenTelemetry end to end#

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter

trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))

from opensmartroute import configure_tracing
from opensmartroute.adapters.optional import OpenTelemetrySink

configure_tracing(OpenTelemetrySink())      # or OSR_OBSERVABILITY_OTEL=true

OTel spans are named osr.route, osr.plan, osr.execute, ...; attributes carry the osr. prefix (osr.target, osr.confidence, osr.request_id, osr.trace_id). OpenTelemetryTelemetry (the older Telemetry port) still records the osr.route_latency_ms histogram per decision; the sink is the richer, per-stage view and the two can run side by side.

Relationship to telemetry and audit#

Telemetry sinks (LoggingTelemetry, MetricsTelemetry, OpenTelemetryTelemetry) observe decisions and outcomes on an EnterpriseRouter; AuditSink keeps a hash-chained, tamper-evident record of who got what. The tracer observes how the decision was made - every stage, with timings, on plain and enterprise routers alike. Use audit for compliance, telemetry for dashboards you already have and the tracer for debugging, SLOs per stage and distributed tracing. RouteTrace.explain() remains the human-readable explanation attached to a RouteDecision.

MetricsTelemetry is the dashboard store: snapshot() returns decisions_total, decisions_by_target, policy_rejections_total, errors_by_type, route_latency_ms (p50, p95, p99, mean, count), route_latency_histogram_ms, mean_confidence, outcomes_by_target (count, success rate, cost, quality, latency) and uptime_s; prometheus(namespace="osr", extra={...}) renders the same as Prometheus text (osr_route_decisions_total{target="..."}, osr_route_latency_ms_bucket{le="..."}, osr_outcome_cost_usd_total, osr_decision_cache_hits_total, extras as gauges). osr serve and the hosted platform expose it at GET /metrics; RouterBuilder.with_cache(ttl_s, max_size) adds a decision cache whose CacheMiddleware.stats() feed the osr_decision_cache_* series, and with_audit(sink, outcomes=True) chains outcomes into the audit trail alongside decisions.