Observability & tracing model
When a generation fails at 2am you want exactly one question answered: “show me the
full trace for generation X.” The 2.0 backend is built so that question is always
answerable. Every service — Go and Python — speaks OpenTelemetry (OTel), exports traces,
metrics and logs to a self-hosted SigNoz over
OTLP, and stamps a trace_id onto every log line so a span and its logs are one click
apart. This page explains the model: one bootstrap shape, a trace that starts in the
browser, how the trace survives the async queue hop, and how errors are forced onto the
span instead of hiding in the logs.
One bootstrap shape, copied across the fleet
Section titled “One bootstrap shape, copied across the fleet”There is exactly one telemetry init, mirrored everywhere:
- Go calls
telemetry.Init(ctx, "core-database")—core-database/internal/telemetry/telemetry.go,core-mcp/internal/telemetry/telemetry.go. - Python calls
telemetry.setup("core-gateway-consumer"). The canonical helper lives insoundverse-py/src/soundverse/telemetry.pyand is mirrored into each service asapp/core/telemetry.py(the service copies add arecord_errorhelper — see below).
Both wire up all three signals and export them over OTLP/HTTP. The whole exporter stack is switched entirely by environment — names only below, never values:
| Env var NAME | Purpose |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
The SigNoz OTLP ingest URL. The master switch — if unset, no exporters are installed at all. |
OTEL_EXPORTER_OTLP_HEADERS |
Comma-separated key=value list carrying the static bearer that Caddy gates SigNoz ingest with. |
OTEL_EXPORTER_OTLP_PROTOCOL |
Transport/encoding selector read by both bootstraps (the fleet defaults to OTLP/HTTP). |
OTEL_SERVICE_VERSION / GITHUB_SHA |
Sets service.version on every span so SigNoz can group/compare by release. GITHUB_SHA arrives as a Docker build-arg; an explicit OTEL_SERVICE_VERSION wins. |
ENVIRONMENT |
Becomes the deployment.environment resource attribute (local / staging / prod). |
LOG_LEVEL |
debug|info|warn|error (default info). Gates both stdout and the OTLP log bridge, in both languages. |
OTEL_HEARTBEAT_SECONDS |
Interval for the telemetry.heartbeat liveness canary (default 60, 0 disables). |
OTLP env is blanket-injected by the shared deploy template (the STAGING_/PROD_
prefix-strip described in Environment & service discovery),
so the wiring is uniform in code across the fleet. When logs are “missing” for a service in
SigNoz, it is almost always a stale ACA revision predating the pipeline, not a code gap.
Trace ↔ log correlation, on every record
Section titled “Trace ↔ log correlation, on every record”This is the single most useful property. Every log line carries the active span’s
trace_id and span_id:
- Go — a custom trace handler reads the span context off
ctxand addstrace_id/span_idto eachslogrecord; a fanout handler writes every record to both stdout JSON and OTLP. - Python —
LoggingInstrumentorpatches the log-record factory and the JSON formatter renames the fields totrace_id/span_id/service, while aLoggingHandlerships the same record to OTLP.
So in SigNoz you pivot from a slow span straight to its logs, and from a stdout line straight to its trace, by the same id.
What gets spanned automatically
Section titled “What gets spanned automatically”otelhttpis the outer handler on the Go data plane, so the inboundtraceparentis extracted and seeds the server span before any inner handler or DB call runs — one RPC is one connected trace.pgxand Redis calls get child spans under that server span, so you see exactly which query or cache op was slow.core-databaseis the service that gets the error path right for free —otelpgxauto-records query errors onto the span.- The propagator is set explicitly to a composite of W3C TraceContext + Baggage in both languages, so cross-service propagation and the queue-hop round-trip (below) behave identically everywhere.
The trace starts in the browser
Section titled “The trace starts in the browser”The trace does not begin at the gateway — it begins in the user’s browser.
soundverse-saas-2.0/lib/otel/browser.ts
starts a WebTracerProvider (service soundverse-web) that captures document load, user
interactions, fetch/XHR, and injects traceparent onto same-origin /api/* calls — the
seam that connects the browser trace to the BFF.
Browser spans never go straight to SigNoz. They are exported to a same-origin proxy,
app/api/otel/[...signal]/route.ts,
which forwards them to the ingest endpoint and attaches the bearer server-side. Two
deliberate reasons: the SigNoz ingest token must never reach the browser (it is read from
OTEL_EXPORTER_OTLP_HEADERS inside the route), and same-origin avoids CORS + NSG changes so
SigNoz stays locked to the ACA outbound IP.
Two enrichments make RUM searchable:
- A
JourneySpanProcessorstampssession.id(a per-tab id) and the currentui.routeonto every span. Filter SigNoz by onesession.idand you replay a user’s whole journey in order. - A chat generation is wrapped in a single
agentone.runspan viarunGenerationSpan(...). It is force-sampled — aForcedRootSampleralways exports it, overriding the head-sampling ratio inNEXT_PUBLIC_OTEL_SAMPLE_RATIO(default0.25) — because itstrace_idis shown to the user, and a displayed id whose trace was dropped is useless.
Surviving the async queue hop
Section titled “Surviving the async queue hop”The hardest boundary to trace is the async task queue: the gateway commits a task row to Postgres, a worker claims it later, and auto-instrumentation cannot see across a queue. This used to mint a fresh, disconnected trace id. It no longer does — the W3C context rides the task row itself:
-
Inject (gateway).
core-gateway-consumer/app/enforcement/pipeline.pysetsw3c_traceparent=telemetry.inject_traceparent()on theQueueTaskRequest, capturing the live span context. -
Extract (worker).
soundverse-py/src/soundverse/tools/fleet.pycallstelemetry.extract_context(task.w3c_traceparent)and starts thetask.runCONSUMERspan with it as the parent. A blank/legacytraceparentsimply yields a root span — graceful degradation for rows queued before the field existed. -
The trace id reconstructed from that persisted
traceparentis the same id surfaced in chat — so browser → gateway → worker is one connected trace in SigNoz.
Errors land on the trace, not just the logs
Section titled “Errors land on the trace, not just the logs”A failed RPC that only logs an error is invisible in a trace view — the span ends green
and the trace you open to debug a failure looks successful. The systemic finding from the
2026-06 observability audit was exactly this: the happy path was well instrumented, but
errors landed in logs, not spans, so the most-traversed gateway/core-mcp server span
ended UNSET for every mapped error. The auto-instrumentation does not hook the error
path (the gateway aborts via context.abort_with_status(), which the grpc-aio OTel proxy
doesn’t intercept; core-mcp’s MCP transport returns HTTP 200 even for tool failures).
The fix is built on the shared ErrorDetail proto contract
(soundverse-proto/proto/soundverse_proto/common/v1/error.proto
— an ErrorCode enum + ErrorDetail message carrying request_id and trace_id), plus a
small record_error(exc, *, message=…, mark_error=…) helper called at central choke
points so individual handlers can’t forget:
- The gateway’s
ErrorInterceptor(core-gateway-consumer/app/grpc_server/interceptors/errors.py) callsrecord_errorat the catch site and stamps the currenttrace_id(formatted032x) onto everyErrorDetail. Crucially, expected business rejections (rate-limited, not-found, insufficient balance) are recorded withmark_error=False— the cause stays inspectable on the span, but error dashboards aren’t flooded by normal rejections. - core-mcp records on outbound RPC errors via a client interceptor and marks the tool-call
span
ERRORonerr/res.IsError— see core-mcp. - The browser force-samples uncaught errors (they were dropped when no span was active)
and marks failed fetches
ERROR; theagentone.runspan is markedERRORon a handled backend/stream failure, so the force-sampled trace the user opens no longer reads green.
Money-path & backlog metrics
Section titled “Money-path & backlog metrics”Custom metrics make a leaked hold or a backing-up pipeline visible in SigNoz instead of a billing complaint:
core-gateway-consumer/app/core/metrics.pydefinessettlement_failure_totalandrefund_failure_total(low-cardinality:tool_id/statusonly). The worker fleet records ageneration_latency_secondshistogram and atoken_usage_totalcounter.core-database/internal/telemetry/dbmetrics.goregisters two observable gauges off the Postgres pool —generation_queue_depth(queued tasks awaiting a worker, by tool) andunsettled_task_age_seconds(age of the oldest terminal task whose reservation the reconciler hasn’t settled). Best-effort: logged-and-skipped on a query error, never fatal.
Money-path non-fatal failures are marked with a filterable attribute
(settlement.failed / refund.failed) rather than ERROR status, so they don’t inflate the
error rate. See The token ledger & money path and
Billing reconciliation.
Reading one trace, end to end
Section titled “Reading one trace, end to end”Under a single trace id you should expect these spans, by tier:
flowchart LR
B["Browser RUM<br/>agentone.run (force-sampled)"]:::frontend
BFF["Next.js BFF<br/>instrumentation.ts"]:::frontend
GW["core-gateway-consumer<br/>resolve → price → reserve → queue"]:::gateway
DB["core-database<br/>otelhttp + pgx / Redis children"]:::data
Q[("generation.tasks<br/>w3c_traceparent on the row")]:::data
W["worker fleet<br/>task.run (CONSUMER) + agent.* steps"]:::worker
SIG["SigNoz (OTLP)"]:::external
B -->|traceparent on /api/*| BFF
BFF -->|otel-interceptor injects onto gRPC| GW
GW --> DB
GW -->|QueueTask + w3c_traceparent| Q
Q -->|ClaimNextTask| W
B -.OTLP.-> SIG
BFF -.OTLP.-> SIG
GW -.OTLP.-> SIG
DB -.OTLP.-> SIG
W -.OTLP.-> SIG
classDef frontend fill:#6366f1,color:#fff,stroke:#4338ca
classDef gateway fill:#0ea5e9,color:#fff,stroke:#0369a1
classDef data fill:#8b5cf6,color:#fff,stroke:#6d28d9
classDef worker fill:#10b981,color:#fff,stroke:#047857
classDef external fill:#f59e0b,color:#111,stroke:#b45309
The BFF is the one hop auto-instrumentation can’t see: instrumentation.ts extracts the
inbound traceparent and lib/grpc/otel-interceptor.ts re-injects it onto the outbound
gRPC call to the gateway — without it the trace would dead-end at the BFF. Inside the worker,
core-tool-agent adds agent.* spans for its own reasoning/tool steps (the second billing
pipeline in the agent + MCP path).
Build provenance. Each span carries service.version, and core-database’s /healthz
also reports build metadata for the service and the generated proto-go stubs it was
compiled against (commit, ref, and the stub source_commit) — so when a trace looks
wrong you can confirm exactly which build produced it, and whether it was on a stale proto.
See the proto → deploy cascade.
Known gaps honest debt
Section titled “Known gaps ”Most of the worst holes flagged by the 2026-06 infra review are closed (the queue-hop trace, OTel in the worker fleet, the money-path counters). What remains:
- Metrics exist; SLOs and alerts do not. Planned We emit
settlement_failure_total,refund_failure_total,generation_latency_secondsandtoken_usage_total, but nothing is wired to page on them and there is no p99-time-to-settle target. Counters nobody watches are half the job. - Always-export-on-error needs the tail-sampling collector.
In code, not deployed Error visibility today relies on
force-sampling at known roots (
agentone.run, uncaught-error spans). A trace that errors downstream after being head-sampled out can still be lost. The intended fix — a tail-sampling collector that keeps any trace containing anERRORregardless of head ratio — exists as config (soundverse-proto/infra/signoz/otelcol-tail.yaml) but is not deployed. - Generation workers have no
/healthz. The Go services expose one; the worker fleet’s liveness is a short-TTL Redis heartbeat key ({env}:worker:active:{worker_id}) that nothing currently alerts on. Workers do push OTLP, so a hung-but-alive worker is partly observable.
See Known limitations for the full risk register.
Related
Section titled “Related”- Observability in practice — the SigNoz query runbook (EE v5 API, Viewer key,
LOG_LEVEL) - Request lifecycle — the generation path a trace follows
- The agent + MCP path — where the
agent.*spans come from - core-mcp — the second error/billing choke point
- Glossary —
trace_id,traceparent, OTLP, force-sample