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

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.
This commit is contained in:
Julien Gautier
2026-05-09 22:05:54 +02:00
parent fc9b63f24a
commit 2e663e1513
14 changed files with 1419 additions and 38 deletions
+32 -10
View File
@@ -4,7 +4,35 @@
# Postgres connection (per ADR-0006)
# Local dev default: dockerised Postgres on port 5432, schema 'public'.
DATABASE_URL="postgresql://portal:portal@localhost:5432/portal_dev?schema=public"
# Username / password / db must match infra/local/.env (POSTGRES_USER /
# POSTGRES_PASSWORD / POSTGRES_DB) — those are the source of truth,
# this is the BFF view of the same connection.
#
# IMPORTANT — URL encoding. The password is part of the URL userinfo
# segment, so any of these characters must be URL-encoded:
# @ → %40 # → %23 : → %3A / → %2F ? → %3F
# % → %25 & → %26 = → %3D + → %2B ; → %3B
# i.e. if your POSTGRES_PASSWORD is "p@ss#1", DATABASE_URL must read
# "postgresql://portal:p%40ss%231@localhost:5432/portal_dev?schema=public"
# The BFF aborts at boot with a clear error if it detects an unencoded
# special character (see apps/portal-bff/src/config/check-database-url.ts).
DATABASE_URL="postgresql://portal:portal_dev_change_me@localhost:5432/portal_dev?schema=public"
# Observability (per ADR-0012)
# All OTEL_* keys are honoured by the OpenTelemetry SDK directly — see
# apps/portal-bff/src/observability/tracing.ts for the bootstrap.
# Pino log level: 'info' in prod, 'debug' in dev (default if unset).
LOG_LEVEL=debug
OTEL_SERVICE_NAME=portal-bff
OTEL_SERVICE_VERSION=dev
# Default endpoint targets the Collector provisioned in
# infra/local/dev.compose.yml. The /v1/traces suffix is required by
# the HTTP/Protobuf transport.
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# v1 samples 100 % at the app; tail sampling is delegated to the
# Collector (per ADR-0012). Override only for spike investigations.
OTEL_TRACES_SAMPLER=always_on
# Future env vars introduced by upcoming phases / ADRs:
#
@@ -28,15 +56,9 @@ DATABASE_URL="postgresql://portal:portal@localhost:5432/portal_dev?schema=public
# MFA (ADR-0011):
# MFA_FRESHNESS_SECONDS (default 600)
#
# Observability (ADR-0012):
# LOG_LEVEL ('info' in prod, 'debug' in dev)
# LOG_USER_ID_SALT (per-environment salt)
# OTEL_SERVICE_NAME ('portal-bff')
# OTEL_SERVICE_VERSION
# OTEL_RESOURCE_ATTRIBUTES
# OTEL_EXPORTER_OTLP_ENDPOINT
# OTEL_EXPORTER_OTLP_PROTOCOL ('http/protobuf')
# OTEL_TRACES_SAMPLER ('always_on' in v1)
# Observability — additional keys to be wired as features land:
# LOG_USER_ID_SALT (per-environment salt for hashing user_id
# in CLS context — needed once auth lands)
#
# Audit trail (ADR-0013):
# AUDIT_DATABASE_URL (separate creds, role 'audit_writer')