Distributed tracing
One traceId from frontend through every api hop — automatic W3C propagation, ctx.request.traceId, traceId-in-logs, the dev error-surfacing toast, and the DevTools / cloud Traces panel.
Trace continuity is automatic and on by default in voltro dev — a buffer-only tracer is always installed (set VOLTRO_TRACING_BUFFER=off to disable). You don't wire anything. A single request gets one traceId that flows frontend → api → api, and every log line of that request carries it.
How propagation works
- Frontend → server is automatic.
useMutation/useSubscriptionwrap each rpc call in a client span;@effect/rpcpropagates that span's W3C trace id to the server, so the handler span — and everything it calls — shares the sametraceId. In any handler,ctx.request.traceIdis that id. - api → api is automatic over HTTP. An action or handler that calls another api via the framework
HttpClientsends a W3Ctraceparentheader; an inbound*.webhook.tsxroute (an incoming HTTP handler) continues the caller's trace. So frontend → api Y → api Z (via webhook / HTTP) is one trace. - Every log line carries it. Handlers stamp
fields.traceIdonto each log line they emit, sovoltro logs --trace <id>returns the whole chain across hops, in order.
client.mutation.placeOrder traceId=4bf92f35…
└─ mutation.placeOrder traceId=4bf92f35… (api Y, ctx.request.traceId)
└─ webhook/fulfilment traceId=4bf92f35… (api Z, continued via traceparent)Seeing traces
- DevTools "Traces" panel (dev) — a waterfall per request, failed hops tinted red. Backed by the in-memory ring buffer.
- Cloud dashboard's per-app "Traces" page — same waterfall, with history kept in
_voltro_traces(postgres) when durable persistence is enabled (dev: on by default, interesting-only; prod: off by default → use OTLP). The in-memory ring is always the live source; see Durable trace volume below.
Durable trace volume (postgres)
The in-memory ring always keeps EVERY span for live debugging. SEPARATELY, on postgres, the framework can durably mirror a SUBSET of spans into _voltro_traces so the in-app Traces dashboard survives restarts. This is a dev convenience — at real volume durable tracing belongs in an OTLP backend (Tempo / Honeycomb / Datadog), not your OLTP postgres (set OTEL_EXPORTER_OTLP_ENDPOINT). A row-per-span firehose into postgres is what turned this table into ~85% of a production DB. So persistence is environment-aware + fully configurable:
- Default OFF in production,
interestingin dev.voltro dev(notNODE_ENV=production) persists only "interesting" spans.voltro serve/voltro startdefaultNODE_ENV=productionwhen it's unset, so they persist NOTHING and_voltro_tracesis not even created — use OTLP in prod. An explicitNODE_ENVis never overridden. VOLTRO_TRACING_PERSIST=off|errors|interesting|all(overrides the env default everywhere).interesting= error + slow + per-trace root spans, MINUS the high-volume subscription delivery (snapshot/delta) spans.errors= error spans only.all= every non-delivery span.off= no persistence and no table.VOLTRO_TRACING_SLOW_MS(default500) — the "slow" threshold used byinteresting.VOLTRO_TRACING_PERSIST_DELIVERY(default off) — also persist the subscription delivery spans (the firehose; rarely wanted).VOLTRO_TRACING_SAMPLE(0–1, default1) — per-trace sampling (a kept trace keeps all its eligible spans; errors are never sampled out).=0with no explicit mode means off.VOLTRO_TRACING_TTL_HOURS(default24) — the GC prunes rows past the TTL; it loops until the whole over-TTL backlog drains each pass (batched set-based delete), so it always catches up. It WARNs if a pass errors or is shifting very high volume — a silently-failing GC is no longer silent.VOLTRO_TRACING_BUFFER=off— disables the durable flusher + the in-memory tracer entirely (and drops the table).
_voltro_traces has no foreign keys — TRUNCATE TABLE _voltro_traces reclaims the space instantly if it ever grew under old defaults. To get the in-app Traces history in prod anyway, set VOLTRO_TRACING_PERSIST=interesting (keeps it small) — but prefer OTLP for production-grade tracing.
Dev error-surfacing toast
In dev, if any hop of a trace you triggered errors — even when the frontend call itself returned OK (a fire-and-forget emit, a detached workflow, a swallowed downstream error) — an auto-toast appears in the browser pointing at the failing hop and at voltro logs --trace <id>.
This catches the class of failure that's normally invisible: the UI shows success, but a downstream hop quietly failed. The toast surfaces it immediately with the exact traceId to chase.
The debugging loop
When you debug a failure:
- Get the
traceId— from the dev error toast, an error line'sfields.traceId, or/_voltro/inspect/traces?onlyErrors=1. - Run
voltro logs --trace <id>— that's the end-to-end causal chain, every log line of the request across hops, in order.
This is the fastest way to answer "where did this error come from?" — far faster than inferring it from source or from the user's description.
Shipping traces to a vendor (Sentry / Datadog)
The same trace continuity flows to a vendor APM when you install a deep-observability plugin — no OTEL_* env. Each contributes an OTel span-processor to the framework's tracer via contributeObservability, so the vendor runs as a consumer of the existing tracer (the in-app Traces panel keeps working) and receives the identical spans:
@voltro/plugin-sentry— every mutation/query/action error becomes a Sentry issue correlated to itstrace_id+span_id, with the request's log lines as breadcrumbs; opt-in performance traces (traces: true).@voltro/plugin-datadog— framework spans → the Datadog Agent's OTLP receiver (traces: true), plus log forwarding withdd.trace_idcorrelation.
See Observability › Routing traces to a vendor for the contribution surface + the sampler caveat.
"Missing peer" warnings on install (benign)
pnpm install may print missing-peer warnings for @opentelemetry/sdk-logs and @opentelemetry/sdk-trace-web — these are optional peers of @effect/opentelemetry (pulled in transitively), used only if you export logs/browser traces to OTLP. Boot and the in-memory trace ring work without them, so the warnings are safe to ignore. To silence them, either add the two packages to your app, or add a pnpm rule:
// package.json
"pnpm": { "peerDependencyRules": { "ignoreMissing": ["@opentelemetry/sdk-logs", "@opentelemetry/sdk-trace-web"] } }