feat(portal-bff): observability foundations (Pino + CLS + OTel)
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:
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Sanity-check `DATABASE_URL` early in bootstrap so a malformed value
|
||||
* fails fast with a clear, actionable message instead of an opaque
|
||||
* Prisma error deep into the request lifecycle.
|
||||
*
|
||||
* The most common malformation is a literal special character in
|
||||
* POSTGRES_PASSWORD that the contributor forgot to URL-encode (`@`,
|
||||
* `#`, `:`, `/`, `?`, `%`, `&`, `=`, `+`, `;` all require it). This
|
||||
* is the same family of bug that bit pgweb in #63 — pgweb's compose
|
||||
* was switched to discrete CLI flags to bypass URL parsing entirely,
|
||||
* but Prisma requires a URL string, so we have no equivalent escape
|
||||
* hatch here. Documentation + early validation are the v1 fix.
|
||||
*/
|
||||
|
||||
const URL_ENCODE_HINT =
|
||||
'If POSTGRES_PASSWORD contains special characters (@ # : / ? % & = + ;), ' +
|
||||
'they must be URL-encoded in DATABASE_URL — e.g., "@" → "%40", "#" → "%23". ' +
|
||||
'See apps/portal-bff/.env.example for the canonical form.';
|
||||
|
||||
export function assertDatabaseUrl(): void {
|
||||
const raw = process.env['DATABASE_URL'];
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
'DATABASE_URL is not set. Copy apps/portal-bff/.env.example to ' +
|
||||
'apps/portal-bff/.env and adjust.',
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`DATABASE_URL is not a valid URL. ${URL_ENCODE_HINT}`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'postgresql:' && parsed.protocol !== 'postgres:') {
|
||||
throw new Error(
|
||||
`DATABASE_URL must use the "postgresql://" scheme; got "${parsed.protocol}".`,
|
||||
);
|
||||
}
|
||||
|
||||
// Heuristic — multiple "@" before the path almost always means the
|
||||
// password contains a literal "@" that should have been encoded.
|
||||
// Splitting on the parsed pathname isolates the userinfo+host
|
||||
// segment without depending on scheme-specific quirks.
|
||||
const beforePath = raw.split(parsed.pathname || '/')[0] ?? raw;
|
||||
const atCount = (beforePath.match(/@/g) ?? []).length;
|
||||
if (atCount > 1) {
|
||||
throw new Error(
|
||||
`DATABASE_URL has ${atCount} "@" before the database path — your password almost ` +
|
||||
`certainly needs URL-encoding. ${URL_ENCODE_HINT}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user