feat(portal-bff): observability foundations (Pino + CLS + OTel) #70
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff/observability-foundations"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements ADR-0012 phase 1, BFF side. The SPA wiring is a separate phase-2 PR.
The BFF now emits structured JSON logs to stdout, tagged with
trace_id/span_idfrom the active OTel context, and exports OTLP traces over HTTP/Protobuf to the Collector that already runs in the local-dev compose. Anything Nest, Express, HTTP-out, Prisma (Postgres) orioredisdoes is auto-spanned. AGET /api/healthliveness endpoint is added to round things out.What lands
Runtime libs added (production deps):
nestjs-pino,pino,pino-http— structured loggingnestjs-cls— request-scoped context@opentelemetry/api/sdk-node/resources/semantic-conventions@opentelemetry/exporter-trace-otlp-proto(HTTP/Protobuf, port 4318)@opentelemetry/instrumentation-{http,express,nestjs-core,pg,ioredis,pino}— curated, noauto-instrumentations-nodemass-import (anti-bricolage)Dev:
pino-pretty(gated byNODE_ENV).Code:
apps/portal-bff/src/observability/tracing.ts— OTelNodeSDKbootstrap. Documents the load-order constraint inline (must be the very first import ofmain.ts). Pure side-effect module.apps/portal-bff/src/observability/observability.module.ts— composesClsModule(UUID per request stored asrequest_id) andLoggerModule(pino-prettyin dev, raw JSON in prod,LOG_LEVELenv-driven,/healthexcluded from auto-logging,X-Request-Idhonoured if inbound).apps/portal-bff/src/health/{health.controller,health.module,health.controller.spec}.ts—GET /api/healthreturning{status, uptimeSeconds, service, version}. Cheap liveness only —/readinesslands when dependencies have a readiness story.apps/portal-bff/src/config/check-database-url.{ts,spec.ts}— fail-fast validator called frommain.tsbefore NestFactory boots. Catches the same family of bug that bit pgweb in #63: a literal special character inPOSTGRES_PASSWORDthat needs URL-encoding inDATABASE_URL. Prisma requires a URL string (no discrete-flag escape hatch), so early validation + a clear error message is the v1 mitigation. Six unit tests cover happy path, missing URL, wrong scheme, encoded special chars, literal@in password, malformed URL.Wiring:
main.tsimports./observability/tracingas line 1, then usesapp.get(Logger)fromnestjs-pinowithbufferLogs: trueso early-bootstrap lines are not lost.app.module.tsimportsObservabilityModulefirst, thenPrismaModule, thenHealthModule.apps/portal-bff/.env.examplepromotesLOG_LEVEL,OTEL_SERVICE_NAME,OTEL_SERVICE_VERSION,OTEL_EXPORTER_OTLP_ENDPOINT,OTEL_EXPORTER_OTLP_PROTOCOL,OTEL_TRACES_SAMPLERfrom the "future" comment to active settings — defaults target the local-dev Collector.apps/portal-bff/.env.exampleandinfra/local/.env.examplenow spell out the URL-encoding constraint onPOSTGRES_PASSWORDwith the char-by-char encoding table (@→%40, etc.).ADR-0012 §Confirmation rewritten to distinguish what landed in this PR from what is wired as the corresponding feature ADRs ship (CLS keys for
session_id/user_id_hash/audience,LOG_USER_ID_SALTenforcement, redact list, custom spans, SPA-side SDK, full integration tests, prod Collector config).Trace ↔ log correlation
Automatic via
@opentelemetry/instrumentation-pino— every Pino record getstrace_idandspan_idinjected from the active OTel context. No CLS gymnastics needed for that concern.Verification
Then hit
GET http://localhost:3000/apionce or twice and open http://localhost:16686 — the corresponding spans appear in Jaeger, and Pino logs on stdout carry the matchingtrace_id.Test plan
nx run-many -t lint test buildgreen on this PR's CI run.pnpm auditclean./api/healthreturns the expected JSON.NODE_ENV=production).--profile observabilityactive, traces are visible in Jaeger UI.trace_idas the trace span in Jaeger.Implements ADR-0012 phase 1, BFF side. The SPA wiring is a separate phase-2 PR. Runtime libraries added (production deps): - nestjs-pino, pino, pino-http structured JSON logging - nestjs-cls request-scoped context - @opentelemetry/api / sdk-node / resources / semantic-conventions - @opentelemetry/exporter-trace-otlp-proto OTLP HTTP/Protobuf - @opentelemetry/instrumentation-{http,express,nestjs-core,pg, ioredis,pino} curated, no `auto-instrumentations- node` mass-import (anti-bricolage) Dev dep: pino-pretty (gated by NODE_ENV). Code: - apps/portal-bff/src/observability/tracing.ts — OTel `NodeSDK` bootstrap. Documents the load-order constraint inline (must be the very first import of `main.ts`). Exports nothing — pure side-effect file. - apps/portal-bff/src/observability/observability.module.ts — composes ClsModule (UUID per request stored as `request_id`) and LoggerModule (pino-pretty in dev / raw JSON in prod, LOG_LEVEL env-driven, `/health` excluded from auto-logging). - apps/portal-bff/src/health/{health.controller,health.module}.ts — `GET /api/health` returning `{status, uptimeSeconds, service, version}`. Cheap liveness only — no DB / Redis check; that belongs to a future `/readiness` once dependencies have a readiness story. - apps/portal-bff/src/config/check-database-url.ts — fail-fast validator called from main.ts before NestFactory boots. Catches the same family of bug that bit pgweb in #63: a literal special char in POSTGRES_PASSWORD that needs URL-encoding in DATABASE_URL. Prisma requires a URL string, so we cannot use discrete CLI flags here — early validation + clear error message is the v1 mitigation. Six unit tests cover happy path, missing URL, wrong scheme, encoded special chars, literal `@` in password, malformed URL. Wiring: - main.ts now `import`s `./observability/tracing` as its first line, calls `assertDatabaseUrl()`, then bootstraps Nest with `app.useLogger(app.get(Logger))` from nestjs-pino with `bufferLogs: true` so early-bootstrap lines are not lost. - app.module.ts imports ObservabilityModule first, then the PrismaModule, then HealthModule. - .env.example (BFF and infra/local) document the URL-encoding constraint on POSTGRES_PASSWORD with the exact char-by-char encoding table. Trace ↔ log correlation is automatic via `@opentelemetry/instrumentation-pino`: every Pino record gets `trace_id` / `span_id` injected from the active OTel context. No CLS gymnastics needed for that specific concern. ADR-0012 §Confirmation rewritten to clearly distinguish what landed in this PR (phase 1) from what is wired as the corresponding feature ADRs ship (CLS keys for session/user/ audience, LOG_USER_ID_SALT, redact list, custom spans, SPA-side SDK, full integration tests, prod Collector config). Verified locally: - pnpm exec nx run-many -t lint test build → 8 projects green, 4 test suites for portal-bff, 9 unit tests pass. - `pnpm nx serve portal-bff` boots, `curl /api/health` returns the expected JSON; logs are pretty-printed JSON one-liners on stdout in dev. - `pnpm audit --audit-level=moderate` reports 0 vulnerabilities.