feat(agent-bff): bundle opentelemetry apm in the docker image - #1849
feat(agent-bff): bundle opentelemetry apm in the docker image#1849nbouliol wants to merge 12 commits into
Conversation
3 new issues
|
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (4)
🛟 Help
|
0eb3c36 to
61a27a9
Compare
86d36a1 to
6ee3de8
Compare
6ee3de8 to
20670ef
Compare
20670ef to
0e9a5d3
Compare
Off until OTEL_EXPORTER_OTLP_ENDPOINT is set. The SDK is armed by a --require preload so the auto-instrumentation patches land before the app imports anything, and spans are flushed on SIGTERM/SIGINT before the signal is re-raised. The packages are pinned in the image's isolated dependency tree only, never shipped to npm consumers, so scan-gate blocks on them again: they exist here and can only be fixed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also maps host.docker.internal in the APM docker run example, which Docker Engine for Linux does not resolve on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The real require can only ever take the failure branch outside the Docker image, so the package ids the loader asks for were never exercised — a typo in one would have surfaced as an untraced image and nothing else. Passing a loader covers the success branch and asserts the three ids. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The OTel spec defines its boolean variables as case-insensitive, and this one is the kill switch: OTEL_SDK_DISABLED=TRUE left tracing running for someone asking it to stop. A blank OTEL_EXPORTER_OTLP_ENDPOINT now counts as unset too — env_file passes an empty string through for a line left blank, which would have armed the SDK against the OTel default endpoint. Also corrects the npm note: outside Docker nothing loads the preload, so an OTEL_* variable is read by nothing — there is no warning, contrary to what it claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n flush The flush re-raised the signal so the default action would terminate. At PID 1 there is no default action: the container survived the re-raise and was SIGKILLed anyway, which could cut the flush it was there to protect. Verified — exit 137 became exit 0. The BFF now has a real shutdown handler that closes the server and exits explicitly, so this only flushes and lets it own the exit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… handler Two problems with the flush being its own SIGTERM listener, both raised in review. It armed at --require time, before the CLI arms its shutdown handler. A signal in that window was consumed by a listener that terminates nothing, so the process ran on until SIGKILL — the same PID 1 trap as before, one layer up. And it was detached, so the exit could cut an export still in flight. The preload now parks the SDK on a shared handle and arms nothing; armShutdown flushes through it and waits, so the exit cannot race the export. The flush gets its own 2s deadline rather than the 10s in-flight requests get: telemetry is not worth holding a shutdown past an orchestrator's patience. Worst case a dead collector adds ~3s. Verified: exit 0 either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OTEL_EXPORTER_OTLP_ENDPOINT was logged verbatim, so an endpoint carrying basic auth put the token in the container logs. This package echoes no other secret anywhere. Userinfo is stripped and the rest of the URL kept, so the line still says where spans go. An endpoint that does not parse is dropped from the log rather than passed through: it cannot be redacted, so it cannot be shown. Verified in the container — zero occurrences of the secret in docker logs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…EL_TRACES_EXPORTER Promise.all is fail-fast, so a rejected stop() exited while the export was still in flight and the force-exit fallback cut it — losing exactly the spans that explain the failed shutdown. allSettled waits for both and still reports the server's rejection. Supplying traceExporter unconditionally put NodeSDK on its manual path, where it never reads OTEL_TRACES_EXPORTER: setting it to none still exported. It is omitted on none now, so the SDK configures no exporter while the instrumentations stay on and trace context keeps propagating. Not flushing on the openapi path, though it drops that command's spans: calling shutdown on the SDK forces an export whose retries hold the event loop long after the flush stops being awaited. Measured at 9s against an unreachable collector where the command otherwise exits in 1.3s — too steep for a one-shot export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot own Only "none" was special-cased, so OTEL_TRACES_EXPORTER=console or zipkin still got an OTLPTraceExporter and exported to the OTLP endpoint — telemetry going somewhere the operator never asked for, which is worse than not exporting at all. We now supply an exporter only when nothing else was requested: unset, or "otlp" said out loud. Everything else is handed to the SDK to configure from the environment, and the log line names the exporter that was asked for rather than an endpoint it will not use. Instrumentation stays on throughout, so context keeps propagating. Also carries --stop-timeout 15 into the APM docker run example. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
be4c5a9 to
d5a1f19
Compare
Tonours
left a comment
There was a problem hiding this comment.
Good layer, and the design is better than the description says (more on that below). Splitting tracing.ts (pure setup) from tracing-preload.ts (the --require) from tracing-handle.ts (the one shared bit of state) is the right split, and wiring the flush into armShutdown instead of letting the preload arm its own handler is what makes the exit wait for the export rather than race it. 19 tests on shutdown.ts covering the flush deadline, a failing stop() with a flush still in flight, and the interrupted paths.
I built the image and exercised it rather than take the claims on trust. Built from d5a1f197b, arm64.
| Check | Result |
|---|---|
docker build |
exit 0, 337 MB, ENTRYPOINT node --require .../tracing-preload.js .../cli.js |
| OTel packages shipped | 79 under node_modules/@opentelemetry, node_modules 36 MB -> 152 MB |
smoke-test.sh |
exit 0 |
| Tracing off, no endpoint | no tracing line, docker stop in 0s |
| Tracing on, collector unreachable | OpenTelemetry tracing enabled, docker stop in 3s, exit 0 |
| Flush deadline | Shutting down -> Forest BFF stopped = 2.006s, exactly flushMs |
OTEL_SDK_DISABLED=TRUE |
disabled, the uppercase fix holds |
| Credential redaction | https://user:s3cret@collector.example:4318 logged as https://collector.example:4318/, zero occurrences of the secret |
So the bounded() deadline does exactly what it promises: a dead collector costs 2 seconds, not the 10 the in-flight requests get, and the process still ends on 0. The "~3 seconds" in the README is the real number.
Two things on the description itself, since neither is a line in the diff:
The "signal handler re-raises" section is stale. It describes once plus a re-raise of the same signal, and none of that is in the final code: 4a087d940 and 6a8bf5cae moved the flush into armShutdown through tracing-handle, and tracing.ts:13-14 now says arming a handler there would be the bug. Worth rewriting before merge, it's the part reviewers will read, and it points them at a mechanism that no longer exists.
The size cost isn't mentioned. 271 MB -> 337 MB, +24%, and node_modules more than quadruples, for a layer that is off by default. Nothing to fix, auto-instrumentations-node pulls the 79 packages and that's the price. But #1848 put "image 271 MB" in its description, so this one saying nothing means someone finds the number in production instead.
Line comments below. Nothing blocking, one is worth three lines of guard before merge.
loadOtelModules only caught resolution failures, so a package that loaded without the export we read handed back a truthy object of undefined members. That skipped the "packages not available" branch and threw a TypeError inside the --require, before the entry point ran at all: a dead container, for a layer documented as degrading to a warning. Reproduced, then fixed by checking the three are callable. The preload now also refuses to let anything else out, including whatever a future SDK version throws from start(). Three more from the same review: - either OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_TRACES_EXPORTER turns tracing on. Gating on the endpoint alone made OTEL_TRACES_EXPORTER=console need an OTLP collector to do anything, which is nonsense for an exporter writing to stdout. - serviceName is passed only when the environment names no service, so OTEL_RESOURCE_ATTRIBUTES=service.name=... is no longer silently overridden by our default. The SDK applies the spec's precedence for the rest. - the smoke test now stops the fully-configured container and times it, with tracing armed against a collector that is not listening. That path is the riskiest code in the image and nothing exercised it there. Measured: exit 0 in 3s, 2.005s between "Shutting down" and "Forest BFF stopped". Also drops the unused OTEL_DEPENDENCIES export and records what keeping those pins current costs, since a fixable CVE in that tree blocks every publish of the image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ring it OTEL_EXPORTER_OTLP_PROTOCOL=grpc still got the HTTP exporter, and setting only OTEL_EXPORTER_OTLP_TRACES_ENDPOINT armed nothing at all. Both are the same root cause as the two rounds before them: passing traceExporter puts NodeSDK on its manual path, where it stops reading the environment. Special-casing one variable at a time was never going to end. So we pass no exporter. sdk-node already depends on every OTLP exporter and on the Zipkin one — verified in the image — so leaving the choice to the SDK costs no new dependency and makes the whole standard surface work: exporter, protocol, and per-signal endpoint and protocol overrides. Our decisions are now only whether to arm at all and what to call the service when nothing else does. Activation also counts OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, and the log line reports what was asked for rather than a destination we no longer choose. Corrects the README too: it claimed only the OTLP exporter shipped in the image, which was wrong — console and zipkin are both there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The manifest names three packages; every OTLP exporter and the Zipkin one arrive transitively, which is what lets the image honour OTEL_TRACES_EXPORTER and OTEL_EXPORTER_OTLP_PROTOCOL without the app choosing an exporter itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tonours
left a comment
There was a problem hiding this comment.
Approving. I went back through all 16 findings and checked each one against the code at 50d3f1d, not against the replies.
What I verified:
- Signal handling —
tracing.tsarms nothing, the SDK is parked ontracing-handleandarmShutdownis the only terminating handler. Pinned bytracing.test.ts:239. - Credentials in the logs —
redactEndpoint(tracing.ts:121), applied at the log line. Pinned, user:password and token-as-user. OTEL_SDK_DISABLED— trim + lowercase, pinned overTRUE/True/' true '.Promise.allon the shutdown path — nowallSettledwith the server result read out of it. Pinned byshutdown.test.ts:118.- Module that resolves without the export we read — every export checked callable, plus the preload catching whatever is left. Pinned per package.
- Exporter, protocol and per-signal endpoint — root cause removed rather than patched per variable: no
traceExporterat all, the SDK reads the environment. Pinned. - The flush path in the image —
smoke-test.sh:160-214arms OTLP against a dead collector on the second boot and asserts exit 0 with a bounded elapsed. Confirmed it runs in CI on this head (docker-publish-bffrun 32994136258, success), so this and the scan gate are actually exercised.
Two things I did not ask you to change: openapi not flushing is the right call at 9s versus 1.3s, and the options.env seam stopping short of where spans go is the honest boundary — both are documented where someone will hit them.
One nit, not blocking: the fix for the OTEL_TRACES_EXPORTER gate left three comments behind saying tracing only arms on OTEL_EXPORTER_OTLP_ENDPOINT — Dockerfile:114, tracing.ts:10, and the variable list at tracing.ts:15-20 which omits OTEL_TRACES_EXPORTER and OTEL_EXPORTER_OTLP_TRACES_ENDPOINT. The README table is correct; these are not.
Note for merge order: this stacks on feat/agent-bff-docker-image, so the parent goes first.

Bundles OpenTelemetry APM into the
agent-bffDocker image, the way the workflow-executor image already does.How it works
src/tracing.tsholds the setup;src/tracing-preload.tsis the two-line--requireentry point the Dockerfile'sENTRYPOINTloads beforecli.js, so the auto-instrumentation patches land before the app imports anything. Keeping them apart is what lets the setup be imported by a test without arming an SDK.Tracing is off until
OTEL_EXPORTER_OTLP_ENDPOINTis set, so a deployment that never opted into APM pays nothing but the require. Configuration is entirely standard OTel environment variables (OTEL_SERVICE_NAME,OTEL_RESOURCE_ATTRIBUTES,OTEL_SDK_DISABLED).The packages are pinned in the image's isolated dependency tree only, never shipped to npm consumers — hence the dynamic require behind a seam, and a warning rather than a crash when they are absent.
Two things worth a look
The signal handler re-raises. The BFF installs no signal handler of its own, and registering a listener suppresses Node's default termination — arming one to flush spans without re-raising would leave
docker stopwaiting out its timeout for a SIGKILL. So the handler flushes, then re-raises the same signal;oncehas already removed the listener, so the second delivery finds none and terminates. A rejected flush re-raises anyway, so a dead collector cannot hang the process.scan-gate.jsblocks again.@opentelemetry/*exists only in this image and can only be fixed here, so a fixable CRITICAL/HIGH in one of them now fails the publish. Everything else stays report-only.**/@opentelemetry/propagator-jaegeris pinned to 2.9.0 (CVE-2026-59892) —auto-instrumentations-nodestill asks for 2.8.0.Verified locally (arm64)
OpenTelemetry tracing enabledin the logs — the packages exist only in the image, so nothing else would prove they are loadableyarn auditon the regenerated isolated lockfile: 0 vulnerabilities across 335 packagesloadand signal paths through injected seams); the full agent-bff suite is green at 1217 tests🤖 Generated with Claude Code
Note
Bundle OpenTelemetry APM into agent-bff Docker image
tracing-preload.tsloaded vianode --requirein the Dockerfile ENTRYPOINT so auto-instrumentation is applied before app modules load. Initialization is gated onOTEL_EXPORTER_OTLP_ENDPOINTand stays inert otherwise.tracing.tswith environment-driven SDK setup, OTLP exporter selection, credential redaction in logs, and a dynamic module loader that tolerates missing OTel packages outside Docker.shutdown.tsto run a bounded telemetry flush in parallel with server stop; flush failures do not affect exit codes.build-deps-manifest.jsto merge pinned@opentelemetry/*packages into the Docker dependency install, andscan-gate.jsnow fails the image publish on vulnerabilities found in Docker-only@opentelemetry/*packages.scan-gate.jsnow callsprocess.exit(1)on vulnerabilities in@opentelemetry/*packages; bumping versions inOTEL_DEPENDENCIESrequires refreshingdeps/yarn.lock.Changes since #1849 opened
initTracingto activate when eitherOTEL_EXPORTER_OTLP_ENDPOINTorOTEL_TRACES_EXPORTERenvironment variables contain non-blank values, treating blank values as unset, while continuing to respectOTEL_SDK_DISABLEDas a short-circuit [296a6c2]setTracingHandle(initTracing())call intracing-preload.tswith try-catch to prevent exceptions during--requirepreload from aborting the process, logging a warning and continuing untraced on failure [296a6c2]serviceNameFromEnvutility and modifiedinitTracingto respect OpenTelemetry specification precedence for service name resolution, only applying default when environment provides no name [296a6c2]loadOtelModulesto verify that resolved OpenTelemetry packages export the expected functions (NodeSDK,getNodeAutoInstrumentations,OTLPTraceExporter), returning undefined if any export is missing or non-function [296a6c2]docker/smoke-test.shto enable tracing viaOTEL_EXPORTER_OTLP_ENDPOINTand validate graceful shutdown within bounded time when collector is unreachable [296a6c2]tracing.test.tsandtracing-preload.test.tsto verify new enablement conditions, service name precedence, module export validation, and error handling during initialization [296a6c2]OTEL_DEPENDENCIESexport fromdocker/build-deps-manifest.jsmodule and extended comments explaining pinned OpenTelemetry dependency policy and Renovate constraints [296a6c2]OTLPTraceExporterfrom@opentelemetry/exporter-trace-otlp-httpin theagent-bfftracing initialization [5a07686]agent-bffREADME [5a07686]agent-bfftracing test suite to removeOTLPTraceExporterexpectations and validate SDK-based configuration [5a07686]Macroscope summarized d5a1f19.