ADR 0035 — Trace without a collector, and the path between layers in the log
Context
ADR 0026 designed
end-to-end correlation: a session is a root trace, traceparent travels
through the three injection points, and all three services log JSON with
the same trace_id. None of that was wrong. The problem is that, in
development, none of it existed — and development is where a log gets
read with human eyes.
Decision 9 of that ADR made instrumentation conditional on
OTEL_EXPORTER_OTLP_ENDPOINT, on the grounds that an exporter delivering
to nowhere floods the log with connection failures. That reasoning is
true. The implementation conflated two different things: creating
telemetry and delivering telemetry.
In the api, without the variable, NodeSDK wasn't even constructed.
Without start() there's no context manager or propagator registered, so:
currentTraceId()returnedundefinedand pino'smixinnever stampedtrace_idon any line;- the
traceparentthe web app sends on every request was silently dropped; injectTraceHeadersinjected nothing into calls to the engine.
The engine's gate was worse: inverted. There is (and was) no
:opentelemetry config in the project, so the SDK came up with
otel_configuration's default, which is {opentelemetry_exporter, %{}}
pointing at localhost:4318. The engine paid for a doomed batch in
development and in mix test — exactly the cost the gate claimed to
avoid. And what the gate was actually turning off was
OpentelemetryBandit.setup(), i.e. the extraction of the incoming
traceparent — the one piece that makes the engine's span a child of the
api's span. Engine.Telemetry.Span's moduledoc claimed everything became
a no-op without a collector; that was false, and the test alongside it now
proves the opposite.
Three correlation gaps turned up while investigating, all of them silent:
Engine.Outbox.Eventdidn't declare themetadatacolumn. The struct had no such key,Drain.traceparent/1's first clause was unreachable, and every Oban job was born withtraceparent: nil. The api had been writing the column correctly since Phase 5. No worker read the argument, so fixing only the schema would leave the data inert.- The engine→api
traceparentwas injected insidepost_returning/3, a funnel only for POSTs. The sixReq.getcalls andllm_turn_streamwent out with no trace — the entire read half of the conversation between the services showed up as an orphan trace. chat-stream.tsbypassesapi-client.tsand wasn't sendingtraceparent. It was the only path on the web app with no trace, and the worst possible one: the LLM turn.
On top of that, the original request that started this work: the log was hard for people to read, didn't say where a line came from, and didn't show the path a user's action took across layers.
Decision
1. Instrumenting and exporting are separate decisions. The SDK comes
up always, in both services. OTEL_EXPORTER_OTLP_ENDPOINT now controls
only the destination:
| with endpoint | without endpoint | |
|---|---|---|
| api | OTLP traceExporter | spanProcessors: [NoopSpanProcessor] |
| engine | opentelemetry_exporter default | traces_exporter: :none |
| context and propagation | registered | registered |
The explicit spanProcessors in the api isn't decoration: without it the
SDK falls back to getSpanProcessorsFromEnv(), which with
OTEL_TRACES_EXPORTER empty builds an exporter pointing at
localhost:4318 — the same defect the gate was trying to avoid. And the
list needs at least one element, because sdk.start() only registers the
global tracer provider when spanProcessors.length > 0; with no provider
the span is born invalid and the propagator injects nothing.
Both choices are memory-safe, by construction: NoopSpanProcessor retains
nothing in onEnd, and Erlang's otel_batch_processor calls disable/1
when the exporter is none and starts returning dropped without ever
touching ETS. The alternatives wouldn't be — BatchSpanProcessor with a
dead exporter holds onto 2048 spans and keeps retrying, and
InMemorySpanExporter grows without bound.
2. tracing.ts exports a function, and the side effect lives in
tracing-boot.ts. Having startTracing() as a function lets a spec
import the module without registering global state — which would break
trace-context.spec.ts, which asserts the opposite on purpose. But the
call can't sit in the body of main.ts: TypeScript and SWC hoist all
requires to the top, so a line placed among imports would run after
pg and express load, and the monkey-patch doesn't take on an
already-loaded module. A separate module solves it because require is
synchronous.
3. The path between layers comes from AsyncLocalStorage, not from
spans. A @Traced('<layer>') decorator at the boundaries records class,
method, layer and duration in a per-request context; a global interceptor
emits one line at the end. A span is also opened, but the path doesn't
depend on it — that's what makes the feature work without a collector.
The pattern is the same one drizzle-context.ts already used to carry the
active transaction through the call stack. The context has a cap of 64
steps: without it, a use case calling a repository in a loop would grow
the array for the request's whole lifetime and then serialize all of it
into one log line.
Steps are recorded on method entry, not on exit. Recording on exit would give termination order — the innermost call finishes first, and the path would come out inside-out.
4. @Traced is forbidden under apps/api/src/interfaces/http/**. A
hard rule, and the reason is security. Nest's decorators (SetMetadata
and derivatives) write metadata onto the method's function object;
legacy decorators apply bottom-up, so replacing descriptor.value discards
metadata written below it. On a controller that means a @RequireRole
disappearing — it compiles, it passes the suite, and it's an authorization
hole.
There's nothing to gain by decorating a controller anyway: the interceptor
covers the HTTP boundary for free, reading ExecutionContext.getClass()
and getHandler(). The option with no diff across 30 files is also the
safe one. (The decorator forwards the metadata regardless, as belt and
suspenders.)
Two exclusions for the same technical reason: nothing that returns an
Observable or is a generator. The "is it thenable?" heuristic would
classify both as synchronous and close the span before the stream
produces. traced-llm-provider.chat is an async generator and was left
out; the @Sse handler is covered by the interceptor, which uses
finalize.
5. ownSpan: true for whoever already manages their own span.
CreateSessionUseCase.execute opens session.create, which ADR 0026
designates as the root of the session's trace and which
docs/reference/events.md documents as the root. Wrapping it would make
the decorator's span the root instead: trace_id would still be correct,
and the docs would become false without anything breaking. With
ownSpan, the path step is recorded and no new span is opened.
6. Pretty in development, one JSON line in production. In production,
transport stays absent and pino writes one JSON.stringify per event —
that's a requirement, not aesthetics: Alloy reads the pod's stdout line by
line.
In development pino-pretty runs in-process, as a stream, not via
transport. The reason is concrete: transport runs on a worker thread
and its options pass through structured clone, so messageFormat and
customPrettifiers as functions can't be passed — and those are what
draw the layer tree. The require is wrapped in try/catch, because
pino-pretty is a devDependency and doesn't exist in the production
image; the fallback is JSON, which is the right failure mode for a logger.
In the engine, Engine.Telemetry.LogFields became the single source for
the fields, JsonLogFormatter only serializes, and a new
PrettyLogFormatter covers dev — where dev.exs had been discarding the
timestamp and all metadata, leaving trace_id, session_id and mfa
invisible.
7. trace_id stays a contract, and nothing about it changed. New
fields (path, duration_ms, layer_count, layer, class, fn) go in
as flat siblings. Alloy's stage.json selects by expression rather than
enumerating, so a new sibling key is ignored and Loki's derivedFields
keeps working. The layers array stays out of production: as a string,
path is ~150 bytes; as an array, 5-10× that, and Loki charges by the
byte.
8. The three gaps, closed together. field :metadata on the outbox
schema plus Span.with_session(args["traceparent"], …) and
Logger.metadata(session_id:) in both workers —
Logger.metadata/1 wasn't being called anywhere in the engine, and that's
why the formatter's session_id field always came out missing. A single
headers/0 funnel in EngineApiClient, covering all 8 call sites. And
traceparent in chat-stream.ts.
Consequences
- With zero collector,
pnpm devproduces three log streams sharing the sametrace_id, generated in the browser. That's the difference between being able to follow a user action across processes and not being able to. - One line per request shows the path across layers with each step's
duration. As an indented tree in dev; as the
pathfield in production. - Agents' asynchronous work became genuinely correlatable: the job the
Psychologist agent fires on a session closing now carries the
trace_idof the session that triggered it. Before, every job hadtraceparent: nil. - The read half of the engine→api conversation left orphanhood, including
llm_turn_stream. - The engine stopped paying for a doomed export batch in dev and in the test suite.
- The api's log gained a shape test that didn't exist before: one line in
production,
trace_idat the top, and theredactlist as a contract. The list grew to include the api↔engine service token, which had not been redacted. - CORS's
allowedHeadersbecame explicit. Before, all correlation depended on thecorspackage's default, which reflects back whatever header was requested in the preflight — implicit library behavior, with no test covering it. - The engine gained an access log (there was none before), and refusing a service token stopped being silent.
WEB_LOG_LEVELturned on in k8s:logger.debugwas dead code in every published environment.- Two pieces of text that claimed the opposite of the actual behavior
were corrected:
Engine.Telemetry.Span's moduledoc and cause 1 of "when there's no trace" in the runbook — the latter was already false for the engine even before this change.
Known limitations
- The guard runs before the interceptor.
JwtAuthGuard,RateLimitGuardandRolesGuardsit outside the path (~1-3ms, already visible aspgspans in Tempo). The upgrade path is additive: move justrunWithRequestContextinto a middleware and keep the interceptor seeding and emitting. depthunder concurrency is approximate. APromise.allof decorated calls interleaves the depths and the tree comes out skewed. Critical paths are sequential.- The SSE line arrives when the stream closes, via
finalize— possibly minutes after the request began. It's consistent with pino-http'sresline, which also waits for the response to finish. genReqIdwasn't touched. It would be a second correlation key that nothing consumes, alongside the field ADR 0026 calls a contract. Correlation happens viatrace_id.- The web app's log doesn't reach Loki. Alloy only reads pod stdout.
The browser's
trace_idlets a human match the console line to the server-side span — what ADR 0026 recorded still holds. @Tracedis applied only on the critical paths (sessions, actions, auth, and the api↔engine bridge). The rest of the repositories remain outside it, by scope choice, not by obstacle.- No RN was created. Business rules live in
apps/api/src/domain/, which is pure; observability isn't a business rule. Recorded here so it doesn't look like an oversight. - The web app didn't gain an error boundary, and ~20 silent
catch {}s remain silent outside the auth and chat paths. A boundary is a UI decision, and converting the rest is mechanical — both deserve their own change.