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
Database runtime boundaries keep their parent. Awaited work dispatched through another Effect runtime sharing the server's tracing layer keeps the active parent span, including SQL-store calls. Explicit parent spans and explicit new roots take precedence; independent tracing layers do not share context.
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.
The client span exists before the transport proves delivery. If a connection
fails, its traceId can therefore have no matching server span — or the server
may have completed the handler and lost only the response. In rpc-error policy,
use RpcError.kind, never the mere
presence of traceId, to distinguish transport failures from handler failures.
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.
Reading a trace
The recent grouped view omits successful traces made entirely of recognized framework housekeeping spans, such as scheduler-claim polling. It filters those groups before applying the trace limit, so they do not crowd out application requests. Errors and traces that also contain application work remain visible. In the embedded Traces scope, Show background traces includes the housekeeping groups; the same option is available as /_voltro/inspect/traces?includeBackground=1. The bounded raw ring and lookup by trace ID remain unchanged.
A trace explains one operation's timing and failures. Each span is a timed step: the handler, a database call or an outbound HTTP request. Start with the operation name and total duration, then expand a slow or failed step to inspect its recorded attributes and error. Attributes can identify the RPC tag, SQL operation or HTTP target; only information actually recorded by the instrumentation is shown.
For example, a refresh taking two seconds with a 1 ms database span probably spends its time elsewhere. A single sql.execute span under 1 ms only tells you that one database operation was fast. It does not identify the button that caused it, and it does not prove the whole refresh succeeded. If the caller was not captured, the panel says so rather than inventing a request relationship. Trace durations overlap when child work runs concurrently, so do not sum the bars as total request time.
The embedded console refreshes its recent traces every four seconds. Selecting a trace keeps its latest captured version and selected row even after the bounded recent feed evicts it. The detail marks this as a saved selection; selecting another entry releases it. While the trace remains in the live feed, newly received spans still update the selection.
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, MINUS the framework's own background-task reads, MINUS a fast query or transaction that no operation wraps (sql.execute,store.*as a root).errors= error spans only.all= every non-delivery span.off= no persistence and no table.Why a bare query is not a root worth keeping. A query or transaction that runs outside any request, job or schedule span — a poller's, a background drain's — has no parent, so it counts as a root. It is not the entry of anything a person asked for. A demo measured 5.3 million spans in 24 hours, 5.28 million of them such bare
sql.execute/store.transactionalroots: 3.3 GB of_voltro_traces, each span one more write on the database it describes. They are kept when slow or failing, like any other span.Why background tasks are excluded from "slow". The framework polls four of its own tables on a timer (
_voltro_schedule_claims,_voltro_workflow_pending,_voltro_workflow_pauses,_voltro_ai_inferences). Those reads are slow exactly when the database is under pressure — which is when persisting them costs the most. A deployment measured the loop closing on itself:_voltro_tracesat 476 571 rows / 335 MB, writing ~11 INSERTs/s onto the same 15-slot pooler the app read through, 99 % of it framework poller spans. Pool pressure makes the spans slow, slow spans are "interesting", persisting them costs pool. They are still kept when they ERROR, andallmode still keeps everything. A request-path framework table like_voltro_api_keysis NOT excluded — a slow lookup there is a real user waiting.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; server performance traces are on by default (traces: falsedisables them). The browser integration keeps rpc markers inside the sampled page/navigation transaction and links each marker to its Effect/server trace.@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.
@effect/opentelemetry — an optional peer every voltro dev needs
The OpenTelemetry SDK is an optional peer of @voltro/runtime, not pulled in transitively. It is loaded whenever a tracer layer is active — and a trace sink activates the layer just like an exporter does. So:
voltro devneeds it by default: the in-process trace ring above is a sink. OnlyVOLTRO_TRACING_BUFFER=offremoves the need.voltro serveneeds it when the inspect trace ring is on (inspect: { traces: … }orVOLTRO_INSPECT_TRACES), when an exporter is on (FRAMEWORK_TRACING,FRAMEWORK_METRICS, anyOTEL_EXPORTER_OTLP_*endpoint), or when an observability plugin contributes a span processor or metric reader. A serve with none of these boots without it.VOLTRO_TRACING_PERSISTdoes not decide it either way — it chooses what the ring persists.
Declare it in the api app, and keep it in every install that boots (a pruned production image, a lockfile rebuilt from scratch):
pnpm add @effect/opentelemetryA boot that needs it and lacks it is a refusal, printed once: it names each cause and the switch that removes it, and the install line. voltro doctor reports @effect/opentelemetry is not installed when the installed @voltro/runtime cannot see it — before a deploy finds out.
"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, 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"] } }