ADR-031 β SigNoz / OpenTelemetry APM (api + agent)
Status: Accepted (foundation shipped via #1230; collector decision below resolves the issue gate) Date: 2026-06-19 Authors: Claude Code agent (foundation), @andrei (review) Issue: #1230 β [P1][feat][observability] SigNoz APM integration β api + agent Related: #1231 (Langfuse LLM tracing β builds on this bootstrap)
Contextβ
The platform had error tracking (Sentry, #828) and structured logs (the agent's
canonical chat_completed line, US-016) but no distributed tracing. There was
no way to see a single request as it crosses the api β agent /chat hop, to
get p50/p95/p99 latency per route, or to build an APM service map. Issue #1230
required choosing a backend, wiring OpenTelemetry into both services, and
propagating trace context across the apiβagent boundary.
humoongate already runs a production SigNoz/OTel integration across its TS
services; the conventions here are lifted from that reference implementation and
adapted to humanwork's APP_ENV + active-active model.
Decisionβ
1. Backend / collector β SigNoz Cloud (US region)β
We export OTLP/HTTP directly to SigNoz Cloud, US region
(https://ingest.us.signoz.cloud:443). We do not self-host an OTel
collector. Rationale:
- A self-hosted collector is stateful infrastructure someone must run, which
contradicts the active-active "stateless pods" rule (repo CLAUDE.md). All pods
export to the same managed endpoint; pods differentiate via the auto-detected
host.nameresource attribute. No per-pod state. - HP already runs a paid SigNoz Cloud tenant for humoongate (same dashboards / alerting team / on-call muscle memory).
- Authentication is a shared-secret
signoz-ingestion-keyheader, stored as a platform secret (Railway / k8s Secret), never committed.
Retention / cost ceiling follow the SigNoz Cloud plan default; revisit if the chat-heavy span volume approaches the plan's GB ceiling (the batch tunables + future tail sampling are the lever).
2. One shared OTel SDK per process; #1231 extends itβ
Each process runs exactly one OTel SDK / TracerProvider. #1230 stands up
that single bootstrap (api/src/instrumentation/tracing.ts,
agent/instrumentation/tracing.py) with the SigNoz exporter. #1231 (Langfuse)
must add a second SpanProcessor/exporter to the same provider β it must not
stand up a parallel SDK (two global providers double-patch and conflict). The
Node bootstrap exports registerSpanProcessor() and marks the extension point
in code for exactly this.
3. Off by default, fail-openβ
Telemetry is OFF unless OTEL_ENABLED=true and SIGNOZ_INGESTION_KEY is
present. Dev / CI / test stay dark (no network export). Init is wrapped so a bad
endpoint / missing key / SDK throw is logged and swallowed β a telemetry error
never crashes the service, mirroring the existing Sentry fail-open posture.
OTEL_SDK_DISABLED=true is a hard kill switch.
4. Load orderβ
OTel must initialize before Sentry and before any app module so its
instrumentations patch http/express/pg/ioredis/undici (api) and
fastapi/httpx/logging (agent) before those modules load. The api imports
./instrumentation/tracing as the first line of main.ts; the agent calls
init_tracing() before FastAPI() is constructed.
5. Cross-service propagationβ
W3C TraceContext (traceparent) is the default propagator. The api injects it
on the outbound /chat call via @opentelemetry/instrumentation-undici
(load-bearing β AgentClient uses native fetch/undici, not axios). The agent
extracts it via opentelemetry-instrumentation-fastapi, making the agent's
/chat span a child of the api span.
6. PII postureβ
ADR-020 isolation classification β Telemetry spans/logs are observability data, not a tenant resource. They carry only pseudonymized identifiers (
org_id/specialist_id/ hashedcustomer_id) and framework/IO metadata. No raw customer PII, message bodies, or tool args are ever placed on SigNoz spans. Raw LLM content belongs to Langfuse (#1231) under SuperAdmin-only access, not here. The (deferred) wide-events interceptor is responsible for stamping the pseudonymized identifiers; the auto- instrumentation in this PR captures HTTP/DB/Redis metadata only.
Service namingβ
humanwork-api-<APP_ENV> and humanwork-agent-<APP_ENV> (e.g.
humanwork-api-staging), derived from APP_ENV when OTEL_SERVICE_NAME is
unset.
Scope of the foundational PR (#1230)β
Shipped: the two SDK bootstraps, load-order wiring, graceful-shutdown flush,
apiβagent propagation via undiciβfastapi, trace-id stamping on the agent's
canonical log lines, env vars, this ADR, and OPENTELEMETRY.md.
Deferred (tracked as #1230 follow-ups): NestJS wide-events interceptor, BullMQ scheduled-task emitter, SigNoz dashboards, the "api 5xx > 1% over 5min β Slack" alert, the Haystack circuit-breaker gauge re-export, and frontend RUM.
Consequencesβ
- Single managed backend, no collector ops, active-active safe.
- #1231 has a clean extension point and will not fork the SDK.
- Telemetry is invisible until explicitly enabled per env, so this PR is a no-op in CI/dev and safe to merge dark.