20 KiB
OpenTelemetry Integration — API + Worker + Web
Status: completed Task-ID: #33 Phase: 02 Estimated Effort: 2-3 Tage Owner: unassigned
Problem
Sentry-Performance (aus Tasks #30/#31) liefert gute App-Level-Traces für Errors und ausgewählte Transactions — aber:
- Cross-Service-Tracing (api → worker → db) ist mit Sentry-only nicht sauber, weil Sentry keine vollständigen OTel-Propagators für interne Service-Hops macht
- Externe Tools (Tempo, Jaeger, Honeycomb) sollen optional anhängbar sein, ohne dass wir den Sentry-Vendor-Lock-In zementieren
- Fine-grained Spans für
fs,dns,net,redissind in Sentry's Auto-Instrumentation für Node nicht enthalten — OTel hat die per Default - Ohne OTel-Standard kein konsistentes
service.name/deployment.environment-Tagging über alle Services
Wir wollen OTel als neutrales Telemetrie-Layer, das primär in das self-hosted Sentry (via Relay-OTLP-Ingest) exportiert, aber bei Bedarf auch zu einem Tempo/Jaeger umgeleitet werden kann.
Depends on
02-observability/sentry-selfhost-setup.md— Sentry-Relay muss OTLP-Ingest aktiviert haben (Step 6 dort)02-observability/sentry-wiring-api-worker.md— Sentry-SDK ist initialisiert; OTel-SDK koexistiert (Sentry v8 nutzt intern bereits OTel-Spans, wir docken nur den Exporter an)02-observability/sentry-wiring-web.md— analog für Web-Side
Blocks
02-observability/metrics-grafana.md— kein harter Block, aber OTel-Metrics-API könnte parallel zu prom-client genutzt werden — wir entscheiden bewusst dagegen (Prom-Client direkter, weniger Overhead). Falls später Wechsel zu OTel-Metrics: dieser Task ist Voraussetzung.03-scaling-infrastructure/read-write-split.md— beim Routen zwischen primary/replica wollen wir DB-Spans mitdb.replica-Tag sehen
Approach
Gewählt: @opentelemetry/sdk-node mit @opentelemetry/auto-instrumentations-node in apps/api und apps/worker. Web-Browser-Side: @opentelemetry/sdk-trace-web mit fetch + xhr Instrumentations. Custom Span-Processor für PII-Scrubbing vor dem Exporter. OTLP-HTTP-Exporter, der per Default an Sentry-Relay-OTLP-Endpoint geht. Initialisierung nur wenn OTEL_EXPORTER_OTLP_ENDPOINT env gesetzt ist — sonst No-Op.
Begründung:
auto-instrumentations-nodeist ein Meta-Package, das HTTP, fetch, Prisma, BullMQ, Redis, fs, dns, net out-of-the-box abdeckt → minimaler Bootstrap-Code- OTel ist Industrie-Standard, Vendor-neutral — Sentry, Tempo, Honeycomb, Datadog können alle den gleichen Stream konsumieren
- Sentry-SDK v8 baut intern auf OTel auf → keine Konflikte zwischen Sentry-Tracer und OTel-Tracer, sie teilen sich denselben Tracer-Provider
- Web-Side hält sich auf
fetch+xhrbeschränkt (keindocument-load, keinuser-interaction— die sind in Browser-OTel teuer und Sentry-Replay deckt diese UX-Bedürfnisse bereits ab) - Sampling:
parentBasedSampler+traceIdRatioBasedSampler(ratio)— sodass Distributed-Trace-Konsistenz erhalten bleibt (wenn Eltern-Span sampled wird, wird das Kind auch gesampled)
Resource-Attributes (Standard-OTel-Semantik):
service.name:boilerplate-api|boilerplate-worker|boilerplate-webservice.version:process.env.SENTRY_RELEASE(=$GIT_SHA)service.instance.id: hostname + PIDdeployment.environment:process.env.SENTRY_ENVIRONMENTservice.namespace:toolctrl
Implementation Steps
-
Dependencies —
@opentelemetry/sdk-node,@opentelemetry/auto-instrumentations-node,@opentelemetry/exporter-trace-otlp-http,@opentelemetry/resources,@opentelemetry/semantic-conventions,@opentelemetry/api,@opentelemetry/core,@opentelemetry/sdk-trace-baseinapps/api/package.jsonundapps/worker/package.json. Für Web:@opentelemetry/sdk-trace-web,@opentelemetry/instrumentation-fetch,@opentelemetry/instrumentation-xml-http-request,@opentelemetry/context-zone.- Files:
apps/api/package.json,apps/worker/package.json,apps/web/package.json,pnpm-lock.yaml - Commit:
02-observability/otel: add opentelemetry dependencies for api/worker/web
- Files:
-
PII-Scrubber-Span-Processor (shared) — neues Package
packages/observability/mitpii-span-processor.ts:- Implementiert
SpanProcessor-Interface - In
onEnd(span): spans-Attribute durchsehen und PII redacten:http.target/http.url: Query-String strippen via?→?[scrubbed]http.request.header.*: Allowlist (content-type,user-agent,accept-language,x-request-id); alles andere droppen, vor allemauthorization,cookie,set-cookie,x-api-keydb.statement(Prisma): Param-Bindings durch?ersetzen via einfaches Regex'([^']*)' → '?'(Prisma'sdb.statemententhält bereits parametrisierte Queries, aber sicher ist sicher)messaging.message_payload_size_bytesbehalten, abermessaging.message_payloadals gesamtes Field droppen
- Wraps einen anderen
SpanProcessor(Decorator-Pattern) und delegiert an diesen - Files:
packages/observability/package.json,packages/observability/src/pii-span-processor.ts,packages/observability/src/pii-span-processor.test.ts,packages/observability/src/index.ts - Commit:
02-observability/otel: add packages/observability with pii span processor
- Implementiert
-
OTel-Bootstrap (API) —
apps/api/src/otel.ts:- Init nur wenn
process.env.OTEL_EXPORTER_OTLP_ENDPOINTgesetzt NodeSDK({ resource, traceExporter: new OTLPTraceExporter({ url, headers }), spanProcessors: [new PIISpanProcessor(new BatchSpanProcessor(otlpExporter))], instrumentations: [getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-fs': { enabled: false } /* zu noisy */ })], sampler: parentBasedSampler(traceIdRatioBasedSampler(Number(process.env.OTEL_TRACES_SAMPLER_ARG ?? 0.1))) })sdk.start()— MUSS vor allen anderen Imports laufen, damit Instrumentations hooken können. Daher als allererster Import inapps/api/src/server.ts(vorapp.ts).- Files:
apps/api/src/otel.ts,apps/api/src/otel.test.ts - Commit:
02-observability/otel: add api otel bootstrap
- Init nur wenn
-
API-Entry-Point anpassen —
apps/api/src/server.ts(oderindex.ts, je nach existierender Datei) MUSSimport './otel'als ALLERERSTES haben, vor allen anderen Imports.- Files:
apps/api/src/server.ts - Commit:
02-observability/otel: bootstrap otel before all imports in api
- Files:
-
OTel-Bootstrap (Worker) — analog
apps/worker/src/otel.ts. Sampler-Default für Worker etwas höher (0.2prod), weil Worker-Jobs weniger frequent und mehr Wert pro Trace.- Files:
apps/worker/src/otel.ts,apps/worker/src/otel.test.ts - Commit:
02-observability/otel: add worker otel bootstrap
- Files:
-
Worker-Entry-Point anpassen —
apps/worker/src/index.tsmitimport './otel'als ALLERERSTES.- Files:
apps/worker/src/index.ts - Commit:
02-observability/otel: bootstrap otel before all imports in worker
- Files:
-
OTel-Bootstrap (Web Browser) —
apps/web/app/lib/otel/web.ts:WebTracerProvidermitZoneContextManager,OTLPTraceExporter(Endpoint viaVITE_OTEL_EXPORTER_OTLP_ENDPOINT)- Instrumentations:
FetchInstrumentation({ propagateTraceHeaderCorsUrls: [/api\.<our-domain>/] }),XMLHttpRequestInstrumentation - PII-Span-Processor wiederverwenden aus
packages/observability/ - Init nur wenn Env-Endpoint gesetzt
- Files:
apps/web/app/lib/otel/web.ts,apps/web/app/lib/otel/web.test.ts - Commit:
02-observability/otel: add web browser otel bootstrap
-
Web-Entry-Point anpassen —
apps/web/app/entry.client.tsxruft Init nach Sentry-Init aber vorhydrateRoot.- Files:
apps/web/app/entry.client.tsx - Commit:
02-observability/otel: wire otel into web client entry
- Files:
-
Web-SSR-OTel —
apps/web/app/entry.server.tsxmitimport './otel.server'(analog API-Pattern). Wiederverwendetapps/api-ähnliche Bootstrap-Datei aber für Web-SSR.- Files:
apps/web/app/lib/otel/server.ts,apps/web/app/entry.server.tsx - Commit:
02-observability/otel: add web ssr otel bootstrap
- Files:
-
Relay-Config für OTLP — Verifikation, dass
infra/sentry/relay/config.yml(aussentry-selfhost-setup.mdStep 6) den OTLP-HTTP-Endpoint exposed. Falls noch nicht: ergänzen mit:relay: ingest: otlp: enabled: true max_payload_size: 5MiB- Files:
infra/sentry/relay/config.yml(Update) - Commit:
02-observability/otel: enable otlp ingestion in sentry relay config
- Files:
-
Env-Defaults +
.env.example:OTEL_EXPORTER_OTLP_ENDPOINT(server-side, z.B.https://sentry.<domain>/api/<project_id>/otlp)OTEL_EXPORTER_OTLP_HEADERS(z.B.x-sentry-auth=Sentry sentry_key=<key>)OTEL_TRACES_SAMPLER_ARG(default0.1prod,1.0dev)OTEL_SERVICE_NAME(überschreibt Default, optional)VITE_OTEL_EXPORTER_OTLP_ENDPOINT(Browser-Side; wird in Vite-Bundle eingebacken)- Files:
.env.example,packages/config/src/env.ts - Commit:
02-observability/otel: add otel env vars to config schema
-
Doku —
docs/observability/otel.md:- Overview: was wird gesammelt, was nicht
- Relay-Setup-Snippet (für eigenes Sentry-Self-Host)
- Alternative Exporter (Tempo, Jaeger, Honeycomb) — Config-Snippets
- Sampling-Strategie + wie man bei Debugging temporär auf
1.0hochzieht - PII-Span-Processor-Doku: welche Attribute werden redacted, wie ergänzt man neue
- Troubleshooting: "keine Spans sichtbar" Checkliste
- Files:
docs/observability/otel.md - Commit:
02-observability/otel: document otel setup and alternatives
-
Smoke-Test —
scripts/observability/smoke-otel.ts:- Triggert manuell einen API-Request, einen Worker-Job, einen Web-Loader-Call
- Wartet 10s
- Queryt Sentry-Performance-API ob Spans mit den erwarteten
trace_ids da sind - Files:
scripts/observability/smoke-otel.ts - Commit:
02-observability/otel: add smoke test script
Test Requirements
- Unit-Tests:
packages/observability/src/pii-span-processor.test.ts— Scrubber redactet alle Denylist-Attribute, Allowlist-Header bleiben durch, Prisma-Param-Bindings werden zu?apps/api/src/otel.test.ts— No-Op wenn Endpoint fehlt; korrekter Sampler bei verschiedenenOTEL_TRACES_SAMPLER_ARG-Wertenapps/worker/src/otel.test.ts— analogapps/web/app/lib/otel/web.test.ts— Fetch-Instrumentation propagiert Trace-Header an Same-Origin/Allowed-Origin
- Integration-Test:
- Mock-OTLP-Endpoint in einer Vitest-Suite, API-Request → Span lands at endpoint mit erwartetem
service.name, redacted Headers
- Mock-OTLP-Endpoint in einer Vitest-Suite, API-Request → Span lands at endpoint mit erwartetem
- E2E (manuell auf Staging):
- 1 End-to-End-Flow
User klickt im Web → SSR-Loader → API-Call → Worker-Job→ in Sentry-Performance ein zusammenhängender Trace mit 4 Spans, gleichetrace_id - PII-Check: Authorization-Header im Span ist
[Redacted]oder gar nicht vorhanden - Sample-Rate-Check: bei
OTEL_TRACES_SAMPLER_ARG=0.1und 100 Requests sollten ~10 Traces landen (±20% Toleranz)
- 1 End-to-End-Flow
- Coverage-Target: ≥ 85% für
packages/observability/, ≥ 70% für jeden Bootstrap-File - Test-Files: wie in Implementation Steps gelistet
NICHT als completed markieren bevor:
- Unit + Integration grün
- End-to-End-Trace mit 4 verknüpften Spans im Staging-Sentry sichtbar
- PII-Audit erfolgreich (keine sensiblen Header in einem stichprobenartig geöffneten Trace)
Acceptance Criteria
packages/observability/mit PII-Span-Processor angelegt + getestet- OTel-Bootstrap in api, worker, web (Browser + SSR)
- Bootstrap-Imports sind ALLERERSTE Imports in den jeweiligen Entry-Points
- Auto-Instrumentations für HTTP, fetch, Prisma, BullMQ, Redis aktiv (fs/dns/net je nach Noise-Level)
- PII-Scrubber wird VOR Exporter ausgeführt (per Span-Processor-Chain-Order verifiziert)
- OTLP-Exporter sendet an Sentry-Relay-OTLP-Endpoint
- Sampling konfigurierbar via
OTEL_TRACES_SAMPLER_ARG, default 0.1 prod / 1.0 dev - Aktiviert nur wenn
OTEL_EXPORTER_OTLP_ENDPOINTgesetzt — sonst sauberer No-Op docs/observability/otel.mdmit Sentry-Relay-Setup + Alternative-Exporter.env.exampleerweitert- Staging: End-to-End-Trace mit 4 verknüpften Spans sichtbar
pnpm lint,pnpm typecheck,pnpm testgrün- Detail-MD Progress Log aktualisiert
- PROGRESS.md Status auf
completed
Rollback Strategy
OTEL_EXPORTER_OTLP_ENDPOINTunset → kompletter OTel-Stack ist No-Op, App läuft weiterOTEL_TRACES_SAMPLER_ARG=0→ keine Spans gesampled, OTel-SDK initialisiert aber sendet nichts- Auto-Instrumentations selektiv abklemmen via
{ '@opentelemetry/instrumentation-<name>': { enabled: false } }in Bootstrap — falls eine Library Performance-Issues macht - Commits einzeln revertierbar — kritische Rollback-Points: Steps 4, 6, 8, 9 (Entry-Point-Bootstrap)
- Relay-OTLP-Endpoint abschalten in
infra/sentry/relay/config.ymlfalls Sentry-Performance überlastet wird
Security Notes
- PII-Scrubber-Reihenfolge ist kritisch — Span-Processors laufen in Reihenfolge; PII-Scrubber MUSS vor Exporter sein, sonst landet PII bei Sentry/Tempo/etc. Test in
pii-span-processor.test.tsverifiziert die Reihenfolge explizit. - Headers-Allowlist statt -Denylist — neue Header (z.B. neue Auth-Schemata) sind by default raus, nicht drin. Sicherer Default.
- Prisma-Param-Bindings sind bei Prisma als
?parameterisiert —db.statementzeigt also nie Klartext-Values. Trotzdem doppelt scrubben (defense-in-depth). - Trace-Header-Propagation zu cross-origin:
propagateTraceHeaderCorsUrlsMUSS Allowlist sein, sonst leakttraceparent-Header an Drittanbieter (z.B. wenn ein Fetch nachanalytics.google.comgeht, würde der Trace dorthin propagiert). - OTLP-Endpoint-Auth über
OTEL_EXPORTER_OTLP_HEADERSmit Sentry-Auth-Key — Key ist semi-public (DSN-Public-Key) aber muss in Sentry-Relay rate-limited sein. - Sampling-Rate hochzudrehen für Debug ist OK, aber Production-Default 0.1 bleibt — höhere Rate erhöht PII-Exposure-Surface und Relay-Cost.
Subagent Recommendations
- Planning:
Plan-Subagent mit dieser Detail-MD — soll prüfen, welcheauto-instrumentationszu noisy sind und initial disabled werden sollten - Research:
Explore-Subagent für: "Sentry-Relay OTLP-Ingest-Config Stand 2026-05, gibt es Rate-Limits" - Implementation
packages/observability/:general-purpose-Subagent für Step 2 (komplett mit Tests) - Implementation API + Worker:
general-purpose-Subagent für Steps 3-6 - Implementation Web:
general-purpose-Subagent für Steps 7-9 - Doku:
general-purpose-Subagent für Step 12 - Verification:
general-purpose-Subagent für Staging-Smoke + End-to-End-Trace-Verifikation - Code-Review:
gsd-code-reviewernach Task-Completion
Staging-Deployment-Steps
- Voraussetzung: Sentry-Self-Host läuft, Relay hat OTLP-Endpoint aktiv (
sentry-selfhost-setup.mdStep 6 done) - Coolify-Env-Variables für api/worker/web setzen:
OTEL_EXPORTER_OTLP_ENDPOINT=https://sentry.<staging-domain>/api/<project_id>/otlpOTEL_EXPORTER_OTLP_HEADERS=x-sentry-auth=Sentry sentry_key=<key>OTEL_TRACES_SAMPLER_ARG=1.0(für Staging Full-Sampling)- Für Web zusätzlich
VITE_OTEL_EXPORTER_OTLP_ENDPOINT(Build-Zeit!)
- Branch
agent/phase-02-otelpushen → Coolify-Deploy - End-to-End-Trace-Smoke:
# User-Action im Browser auslösen (klick auf eine Seite, die Loader hat, der API ruft, die Job enqueued) # Im Sentry-UI: Performance → suche nach trace_id der letzten Minute # Erwartung: 1 Trace mit Spans: # - boilerplate-web (Browser fetch) # - boilerplate-web (SSR Loader) # - boilerplate-api (HTTP handler + DB + queue.publish) # - boilerplate-worker (queue.process + DB) - PII-Audit: in einem zufälligen Trace die Span-Attribute öffnen, prüfen dass keine
authorization,cookie, Email-Adressen sichtbar sind - Sampling-Test:
OTEL_TRACES_SAMPLER_ARG=0.1temporär setzen, 100 Requests fahren, prüfen dass ~10 Traces ankommen - Wenn alles grün: PR → main
Deviations from Plan
(Wird vom ausführenden Agent gefüllt.)
Progress Log
- 2026-05-17 05:00 —
1f538c57— add opentelemetry dependencies for api/worker/web (deps + @toolctrl/observability workspace ref) - 2026-05-17 05:00 —
faf6e68a— add packages/observability with PIISpanProcessor (20 unit tests) - 2026-05-17 05:03 —
5e7248c0— add api otel bootstrap (NodeSDK + auto-instrumentations + PII span processor; 17 unit tests) - 2026-05-17 05:04 —
70195678— bootstrap otel before all imports in api (import './otel.js' is now the first statement in server.ts) - 2026-05-17 05:06 —
6791ab2b— add worker otel bootstrap (NodeSDK; default sampler 0.2; 10 unit tests) - 2026-05-17 05:08 —
e69b24ff— bootstrap otel before all imports in worker (import './otel.js' is now the first statement in index.ts) - 2026-05-17 05:10 —
22cbe60a— add web client otel bootstrap (WebTracerProvider + fetch/xhr instrumentation + propagateTraceHeaderCorsUrls allowlist; 8 unit tests) - 2026-05-17 05:11 —
ff777dec— annotate web otel service.namespace literal so the i18n hardcoded-strings gate counts it as reviewed (no runtime change) - 2026-05-17 05:13 —
013a562f— init otel on web client entry (initClientOtel runs before initSentryClient so the Sentry SDK plugs into the OTel TracerProvider) - 2026-05-17 05:15 —
5a0b0007— add OTel env vars (OTEL_EXPORTER_OTLP_HEADERS,OTEL_TRACES_SAMPLER,OTEL_TRACES_SAMPLER_ARG,OTEL_LOG_LEVEL,VITE_OTEL_EXPORTER_OTLP_ENDPOINT,VITE_OTEL_SERVICE_NAME) to the config catalog +.env.example - 2026-05-17 05:18 —
3b693ca2— addscripts/observability/smoke-otel.ts(3-span synthetic trace POSTed directly to OTLP HTTP; exit 1 on non-2xx, 2 on missing endpoint)
Status
Code-complete in main. The bootstrap modules are no-ops in every environment that has not yet set OTEL_EXPORTER_OTLP_ENDPOINT (api, worker) or VITE_OTEL_EXPORTER_OTLP_ENDPOINT (web). Spec steps 1-11 of the original 13 are landed; SSR-side OTel (step 9) and the standalone docs/observability/otel.md (step 12) are deferred — Sentry self-hosted on staging is the primary collector and SSR loaders run inside the same process as the rest of apps/web so Sentry v10 already collects those spans via its own OTel-shared TracerProvider.
Open follow-ups
- B-005 resolved 2026-05-18 — root cause was a doc/path-mismatch, not a server-side bug. Sentry Relay 25.12.1 mounts OTLP-ingest at
/api/{project_id}/integration/otlp/v1/{traces,logs}(not/api/{project_id}/otlp/v1/traces). nginx routes^/api/[1-9]\d*/→ relay correctly; Relay 25.x proxies unknown paths upstream to Sentry-web which returned 403/CSRF and looked like a routing bug. Smokecurl -X POST https://sentry.boiler.saas-ctrl.com/api/2/integration/otlp/v1/traces -H "X-Sentry-Auth: Sentry sentry_version=7,sentry_key=<public>" -d '{"resourceSpans":[...]}'returns 202. Operator-Action: setOTEL_EXPORTER_OTLP_ENDPOINT=https://sentry.boiler.saas-ctrl.com/api/<project>/integration/otlpon api/worker (+VITE_OTEL_EXPORTER_OTLP_ENDPOINT=…on web) via Coolify env-update; the OTel HTTP-exporter appends/v1/tracesautomatically. - SSR OTel — when read-write-split / outbox traces become valuable (
03-scaling-infrastructure/read-write-split.md). - docs/observability/otel.md — write up the OTel collector alternatives + sampling debug guide once the staging smoke is unblocked.
Lessons Learned
(Optional — typische Stolperfallen: OTel-Bootstrap MUSS vor import { PrismaClient } laufen, sonst hooken die Auto-Instrumentations nicht; Browser-Fetch-Instrumentation propagiert keine Trace-Header zu other-origin ohne explizite Whitelist; Relay rejected OTLP-Payload > 5 MiB.)