Skip to content

Request tracing

New in 5.12. http.tracing gives every request a set of named phase spans and start/end events, delivered to plain callbacks you register in config — no @opentelemetry/api dependency in @warlock.js/core, in this release or later. An OTel (or other vendor) bridge is a separate, optional package that would subscribe to these hooks; it does not exist yet. Off by default, and a disabled app pays exactly one cached boolean check per call site — no context object is built, no performance.now() is called, nothing is allocated.

src/config/http.ts
export default {
tracing: {
enabled: true, // default false
hooks: [
{
onRequestStart(ctx) {
// ctx: { traceId, requestId, method, route, path }
},
onPhase(ctx, phase) {
// phase: { name, durationMs, attrs? }
},
onRequestEnd(ctx, result) {
// result: { status?, durationMs, error? }
},
},
],
},
};

enabled is resolved once at first read and cached for the process — flipping it requires a restart, the same trade-off as http.maintenance.enabled and http.requestId.enabled.

type TracingContext = {
traceId: string; // see "Trace id derivation" below
requestId: string; // request.id
method: string;
route?: string; // matched pattern, e.g. "/users/:id" — undefined before routing
path: string;
};
type TracingPhaseInfo = {
name: string;
durationMs: number;
startedAt?: number; // epoch milliseconds when available
attrs?: Record<string, unknown>;
};
type TracingRequestEndInfo = {
status?: number;
durationMs: number;
error?: unknown;
};
type TracingHooks = {
onRequestStart?(ctx: TracingContext): void;
onRequestEnd?(ctx: TracingContext, result: TracingRequestEndInfo): void;
onPhase?(ctx: TracingContext, phase: TracingPhaseInfo): void;
};

Every verb is optional — a hook that only wants phase spans need not implement onRequestStart/onRequestEnd. Register as many hooks as you like via hooks: TracingHooks[]; each fires independently.

Packages and application code that should not own src/config/http.ts can register a hook directly. Registration turns tracing on immediately; the returned function unregisters that hook. Runtime hooks run after configured hooks.

import { registerTracingHooks } from "@warlock.js/core";
const unregister = registerTracingHooks({
onPhase(ctx, phase) {
metrics.timing("http.phase", phase.durationMs, {
route: ctx.route,
phase: phase.name,
});
},
});
// Call during teardown or when the integration is disabled.
unregister();

core wires five phases into the request lifecycle, in this order, for every HTTP request:

PhaseFires around
route.matchResolving the incoming path/method to a registered route
middlewareEach middleware in the route’s chain — one onPhase call per middleware, with attrs: { name, index }
validationThe route’s input validation (v.object(...) / RESTful resource validation)
handlerThe route handler itself
response.writeThe overall request span, closed once the response has settled (success or thrown error) — this is also where onRequestEnd fires

@warlock.js/web page requests report through this same onPhase surface instead of adding a separate hook API. Alongside loader, render.shell, and stream.end, they add page middleware, cache, and deferred-settlement phases:

PhaseFires around
page.middlewareThe complete page middleware chain, with attrs: { count, outcome }; outcome is next, response, or error.
loaderEach loader level, once per app/layout/page, with attrs: { level, layoutPath? }.
page.cachePage-cache lookup or write, with cache outcome attributes.
translationsBinding the page’s translation snapshot, with attrs: { route, locale, mode }; mode is scoped or global. A snapshot is built once per page source and locale, so warm requests are close to zero.
named-api-routesResolving the document’s browser route table, with attrs: { route }. Production resolves it once per site and viewer kind, so warm requests are close to zero.
render.shellTime from render start until React’s shell is ready to stream.
defer.settleEach deferred value settling, with attrs: { key, status }; status is fulfilled or rejected.
stream.endThe whole streamed response, including every defer() value settling.

Phases that record an absolute start time expose it as startedAt; consumers can use it to place spans on one request timeline rather than relying only on durations.

ctx.traceId is derived once per request:

  1. If the inbound traceparent header is a valid W3C version-00 header (00-<32 hex>-<16 hex>-<2 hex>, trace id not all-zero), traceId is that header’s trace id — so a request already inside someone else’s distributed trace keeps the same id through Warlock.
  2. Otherwise traceId falls back to request.id (the framework’s own per-request correlation id — see Security → Request-id correlation for how that id is generated/inherited/echoed).

ctx.requestId is always request.id, regardless of which branch produced traceId — so a hook can always join back to the same id the framework logs and echoes on X-Request-Id, even when traceId came from an inbound header.

import { deriveTraceId, parseTraceparentTraceId } from "@warlock.js/core";
parseTraceparentTraceId("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
// -> "4bf92f3577b34da6a3ce929d0e0e4736"
deriveTraceId(undefined, "req-abc123");
// -> "req-abc123" (no traceparent, falls back to requestId)

Tracing does not add a response header of its own. core already echoes request.id back as a response header (X-Request-Id by default) on every response — apps correlate through that existing header. When a valid inbound traceparent is present, ctx.traceId inside your hooks carries that trace id even though the response header still reflects request.id; if you need the resolved traceId on the wire (e.g. to hand it back to a caller that sent traceparent), read it from your own onRequestStart/onPhase hook and set it yourself — core deliberately doesn’t duplicate it into a second header.

Every hook call is wrapped: if a hook throws, the dispatcher catches it, reports it once per (hook, verb) pair per process to the error sink, and continues with the next hook. A hook that throws on every request does not flood your logs and never turns an observability bug into a 500 for real traffic.

http.tracing.enabled is resolved once (lazily, on first read) and cached — never re-read per request. Every instrumented call site checks that cached boolean before building a context object or calling performance.now(), so a disabled app pays exactly one boolean check per phase and allocates nothing extra.

Example: log phases slower than a threshold

Section titled “Example: log phases slower than a threshold”
src/config/http.ts
import { log } from "@warlock.js/logger";
const SLOW_MS = 200;
export default {
tracing: {
enabled: env("TRACING_ENABLED") === "true",
hooks: [
{
onPhase(ctx, phase) {
if (phase.durationMs < SLOW_MS) return;
log.warn("http", "slow-phase", {
traceId: ctx.traceId,
requestId: ctx.requestId,
route: ctx.route,
phase: phase.name,
durationMs: phase.durationMs,
attrs: phase.attrs,
});
},
},
],
},
};
  • No OTel dependency, and none planned for core. An OTel (or other vendor) bridge is a separate, optional package that subscribes to these hooks — never add @opentelemetry/api to core itself.
  • web’s phases aren’t live in every release. Confirm your installed @warlock.js/web version fires loader/render.shell/stream.end before relying on them; core’s five phases fire unconditionally once tracing is enabled.
  • route is undefined until routing has matched. There’s no path where a hook fires before that, but code branching on ctx.route for an early-failing request (e.g. a 404 with no match) must handle undefined.
  • traceId is not a second correlation id to store separately by default. It equals requestId unless the caller sent a valid traceparent — don’t assume it’s always a 32-hex OTel-shaped value.
  • Toggling enabled needs a restart. It’s resolved once per process, the same trade-off as http.maintenance.enabled.
  • Security — the rest of Warlock’s transport and middleware security surface.
  • Logging — structured logging; tracing hooks are the place to bridge phase timing into your log channel.