Tracing with OpenTelemetry
DaloyJS ships otelTracing(opts), a hook factory that produces a Hooks object compatible with @opentelemetry/api. It starts a SERVER-kind span per HTTP request, attaches the standard HTTP semantic-convention attributes, exposes the span on ctx.state for handlers, and ends the span exactly once when the response is sent.
The framework does not depend on @opentelemetry/api. You pass any tracer that implements the minimal TracingTracer interface, so the same hook works on Node with the OTel SDK, on Workers with the built-in createOtlpTracer(), or in tests with an in-memory fake.
- 01optionalExtract contextcontextFromRequest reads traceparent / B3
- 02onRequeststart SERVER span + http.request.method, url.path
- 03handlerctx.state.otelSpan for events & child spans
- 04onSendstatus code, http.route, rename to GET /users/:id
- 05exactly oncespan.end()only 5xx sets ERROR + error.type
Quick start
No OTel SDK and just want spans in a collector? Use new App({ telemetry: { traces: true } }) instead, see OTLP export.
That single hook gives every request:
http.request.method,url.path(JWT / credential-shaped path segments replaced with[REDACTED]viasanitizeUrlPathForLog(), since 1.17.0; the default span name uses the same redacted path),url.scheme,server.address(host without port),server.port(when present),url.query(redacted, see below),user_agent.originalset ononRequest.http.response.status_code, andhttp.route(the matched route template, e.g./users/:id) set ononSend.- For
5xxresponses:setStatus(ERROR),error.type(the exception name when one was thrown, otherwise the status code), andrecordExceptionwith the thrown error. - A guaranteed single
span.end()per request, including requests rejected before routing (for example by the cross-origin guard).
Status follows the HTTP semantic conventions
Per the OTel HTTP span status rules, a 4xx is the client's problem, so it leaves a SERVER span's status unset. Only 5xx responses mark the span ERROR. A thrown NotFoundError, a failed validation (400 / 422), or a rejected token (401) therefore no longer shows up as a failed span in Jaeger, Tempo, or your error-rate panel.
| Outcome | Span status | error.type | Exception event |
|---|---|---|---|
2xx / 3xx | unset | - | - |
4xx (returned or thrown) | unset | - | - |
5xx from a thrown error | ERROR with the message | the error name, e.g. TypeError | yes |
5xx returned by the handler | ERROR | the status code, e.g. 503 | - |
Span names stay low-cardinality
A span starts as "<METHOD> <pathname>", because routing has not run yet. When the tracer's spans implement updateName (the @opentelemetry/api SDK and createOtlpTracer() both do), the span is renamed on send:
- matched route:
GET /users/:id, the route template from your route table; - unmatched request: just
GET.
Raw paths carry ids, emails, and anything an attacker types, and they explode the "operations" list of every trace UI. The route template is bounded by your route table. A custom spanName is never renamed.
url.query is redacted by default
Query strings often carry OAuth code values, access_tokens, and presigned-URL signatures. Since 1.3.7, otelTracing() runs the query through sanitizeUrlQueryForLog, the same rules the logger applies to URLs (see Logging): values of sensitive keys and JWT or credential-shaped values become [REDACTED] before the attribute reaches your tracing backend. The redactQuery option (since 1.3.7) changes that:
Reading the active span in handlers
The active span is exposed on ctx.state.otelSpan (key configurable via stateKey). Use it to add events, child spans, or extra attributes from inside a handler:
Child spans
otelTracing() runs as hooks, not as a wrapper around your handler, so it cannot make the request span the active OTel context. Hooks have no "around" phase to call context.with() in. Auto-instrumented libraries (an OTel pg or undici instrumentation) therefore start their spans as new roots unless you activate the span yourself.
With the OTel SDK, wrap the work in context.with:
With createOtlpTracer(), pass the parent explicitly as the third startSpan argument:
Customizing span name and attributes
All extractors are optional. They are merged on top of the defaults so you only need to override what you care about.
Propagating upstream context
DaloyJS does not install a propagator. To continue a trace started by an upstream service, return its context from contextFromRequest. With the OTel SDK, use its propagator:
Without an SDK, the built-in W3C helpers do the same job. parseTraceparent() is strict, because the header is attacker-controlled on a public edge: uppercase hex, all-zero ids, version ff, a malformed version-00 value, and anything over 512 characters are ignored.
Correlating logs with traces
correlateLogs: true rebinds ctx.state.log to a child logger that adds trace_id, span_id, and trace_flags to every line the handler writes. The span must implement spanContext() (the OTel SDK and createOtlpTracer() both do). Invalid or all-zero ids are ignored.
The OTLP log exporter promotes those fields to the log record's trace context, so Grafana, Datadog, and similar backends can jump from a log line straight to its trace. telemetry: { traces: true } turns this on for you.
Built-in OTLP tracer
createOtlpTracer() from @daloyjs/core/otlp is a dependency-free TracingTracer that batches spans and pushes them to an OpenTelemetry collector as OTLP/HTTP JSON (/v1/traces). It reads the same OTEL_EXPORTER_OTLP_* variables as the log and metric exporters, returns null when no endpoint is configured, and runs on Node, Bun, Deno, Cloudflare Workers, and Vercel.
Most apps do not need to write this: new App({ telemetry: { traces: true } }) wires exactly that. See OTLP export for sampling, the environment variables, and the fail-safe limits.
End-to-end with Jaeger (OTLP)
The repository ships a runnable example plus a Jaeger service in the examples/observability/ Docker stack, so you can watch real spans land in a trace UI. The example uses createOtlpTracer(), so there is no @opentelemetry/* SDK and no exporter code to write.
1. Start Jaeger
2. Run the demo app
3. Generate traffic and open Jaeger
Open http://localhost:16686, pick the daloy-otel-demo service, and you will see one SERVER span per request named after its route template: the /boom span flagged as an error with an exception event, the /slow span showing its real duration and a nested child span, and the traceparent request stitched into the upstream trace as a child span.
If you already run the OTel SDK, pass trace.getTracer("svc") from @opentelemetry/api instead. The otelTracing() call does not change. Only the tracer you pass in does.
Lifecycle and limitations
- Request outcomes. Matched routes, unmatched requests (
404/405), OPTIONS preflight responses, and requests rejected before routing (cross-origin guard) all end withhttp.response.status_codeon the same span when the hook is installed withnew App({ hooks }). Installed withapp.use(...), it covers matched routes registered after the call. - No active context. The hook never touches
globalThis, never installs a propagator or context manager, and never imports an OTel SDK, so it stays adapter-portable. See Child spans for parenting work under the request span. - Single end. If a handler throws, the error is remembered and the span is ended once during
onSend, with the final status deciding whether it is an error. LateronError/ repeatonSendinvocations are no-ops. - Composes with other hooks. Combine
otelTracing(...)withrequestId(...),secureHeaders(...), etc., DaloyJS merges global, group, and per-route hooks pipeline-style.