feat(portal-bff): observability foundations (Pino + CLS + OTel) #70

Merged
julien merged 1 commits from feat/portal-bff/observability-foundations into main 2026-05-09 22:28:18 +02:00
Owner

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_id from 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) or ioredis does is auto-spanned. A GET /api/health liveness endpoint is added to round things out.

What lands

Runtime libs added (production deps):

  • nestjs-pino, pino, pino-http — structured logging
  • nestjs-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, no auto-instrumentations-node mass-import (anti-bricolage)

Dev: 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). Pure side-effect module.
  • 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, X-Request-Id honoured if inbound).
  • apps/portal-bff/src/health/{health.controller,health.module,health.controller.spec}.tsGET /api/health returning {status, uptimeSeconds, service, version}. Cheap liveness only — /readiness lands when dependencies have a readiness story.
  • apps/portal-bff/src/config/check-database-url.{ts,spec.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 character in POSTGRES_PASSWORD that needs URL-encoding in DATABASE_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.ts imports ./observability/tracing as line 1, then uses app.get(Logger) from nestjs-pino with bufferLogs: true so early-bootstrap lines are not lost.
  • app.module.ts imports ObservabilityModule first, then PrismaModule, then HealthModule.
  • apps/portal-bff/.env.example promotes LOG_LEVEL, OTEL_SERVICE_NAME, OTEL_SERVICE_VERSION, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_TRACES_SAMPLER from the "future" comment to active settings — defaults target the local-dev Collector.
  • Both apps/portal-bff/.env.example and infra/local/.env.example now spell out the URL-encoding constraint on POSTGRES_PASSWORD with 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_SALT enforcement, redact list, custom spans, SPA-side SDK, full integration tests, prod Collector config).

Trace ↔ log correlation

Automatic via @opentelemetry/instrumentation-pino — every Pino record gets trace_id and span_id injected from the active OTel context. No CLS gymnastics needed for that concern.

Verification

pnpm exec nx run-many -t lint test build       # 8 projects green
pnpm audit --audit-level=moderate              # 0 vulnerabilities
./infra/local/dev.sh up observability          # start Collector + Jaeger
cp apps/portal-bff/.env.example apps/portal-bff/.env
pnpm nx serve portal-bff
curl http://localhost:3000/api/health
# → {"status":"ok","uptimeSeconds":N,"service":"portal-bff","version":"dev"}

Then hit GET http://localhost:3000/api once or twice and open http://localhost:16686 — the corresponding spans appear in Jaeger, and Pino logs on stdout carry the matching trace_id.

Test plan

  • nx run-many -t lint test build green on this PR's CI run.
  • pnpm audit clean.
  • BFF boots, /api/health returns the expected JSON.
  • Pino logs in dev are colourised one-liners; in prod they would be raw JSON (toggled by NODE_ENV=production).
  • With the local-dev stack's --profile observability active, traces are visible in Jaeger UI.
  • Each Pino log line for a request carries the same trace_id as the trace span in Jaeger.
## 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_id` from 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) or `ioredis` does is auto-spanned. A `GET /api/health` liveness endpoint is added to round things out. ## What lands **Runtime libs added** (production deps): - `nestjs-pino`, `pino`, `pino-http` — structured logging - `nestjs-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, **no** `auto-instrumentations-node` mass-import (anti-bricolage) Dev: `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`). Pure side-effect module. - `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, `X-Request-Id` honoured if inbound). - `apps/portal-bff/src/health/{health.controller,health.module,health.controller.spec}.ts` — `GET /api/health` returning `{status, uptimeSeconds, service, version}`. Cheap liveness only — `/readiness` lands when dependencies have a readiness story. - `apps/portal-bff/src/config/check-database-url.{ts,spec.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 character in `POSTGRES_PASSWORD` that needs URL-encoding in `DATABASE_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.ts` imports `./observability/tracing` as line 1, then uses `app.get(Logger)` from `nestjs-pino` with `bufferLogs: true` so early-bootstrap lines are not lost. - `app.module.ts` imports `ObservabilityModule` first, then `PrismaModule`, then `HealthModule`. - `apps/portal-bff/.env.example` promotes `LOG_LEVEL`, `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_TRACES_SAMPLER` from the "future" comment to active settings — defaults target the local-dev Collector. - Both `apps/portal-bff/.env.example` and `infra/local/.env.example` now spell out the URL-encoding constraint on `POSTGRES_PASSWORD` with 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_SALT` enforcement, redact list, custom spans, SPA-side SDK, full integration tests, prod Collector config). ## Trace ↔ log correlation Automatic via `@opentelemetry/instrumentation-pino` — every Pino record gets `trace_id` and `span_id` injected from the active OTel context. No CLS gymnastics needed for that concern. ## Verification ```bash pnpm exec nx run-many -t lint test build # 8 projects green pnpm audit --audit-level=moderate # 0 vulnerabilities ./infra/local/dev.sh up observability # start Collector + Jaeger cp apps/portal-bff/.env.example apps/portal-bff/.env pnpm nx serve portal-bff curl http://localhost:3000/api/health # → {"status":"ok","uptimeSeconds":N,"service":"portal-bff","version":"dev"} ``` Then hit `GET http://localhost:3000/api` once or twice and open http://localhost:16686 — the corresponding spans appear in Jaeger, and Pino logs on stdout carry the matching `trace_id`. ## Test plan - [ ] `nx run-many -t lint test build` green on this PR's CI run. - [ ] `pnpm audit` clean. - [ ] BFF boots, `/api/health` returns the expected JSON. - [ ] Pino logs in dev are colourised one-liners; in prod they would be raw JSON (toggled by `NODE_ENV=production`). - [ ] With the local-dev stack's `--profile observability` active, traces are visible in Jaeger UI. - [ ] Each Pino log line for a request carries the same `trace_id` as the trace span in Jaeger.
julien added 1 commit 2026-05-09 22:24:47 +02:00
feat(portal-bff): observability foundations (Pino + CLS + OTel)
CI / commits (pull_request) Successful in 1m45s
CI / scan (pull_request) Successful in 1m55s
CI / check (pull_request) Successful in 2m8s
CI / a11y (pull_request) Successful in 1m10s
CI / perf (pull_request) Successful in 2m55s
2e663e1513
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.
julien merged commit b74d3f1b9b into main 2026-05-09 22:28:18 +02:00
julien deleted branch feat/portal-bff/observability-foundations 2026-05-09 22:28:19 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#70