feat(portal-shell): wire spa-side opentelemetry tracing (#72)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m15s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 2m0s
CI / check (push) Successful in 43s

## Summary

Phase 2 of ADR-0012 — closes the loop SPA → BFF → DB. After this PR, a single user action (initial page load, click, form submit) produces one trace whose root span is owned by the SPA and whose child spans cover the BFF request, Postgres queries through Prisma, and (eventually) Redis / downstream-API hops.

## What lands

**Browser-side OTel libs** (production deps):

- `@opentelemetry/sdk-trace-web` — browser tracer + provider
- `@opentelemetry/exporter-trace-otlp-http` — OTLP/HTTP+JSON exporter
- `@opentelemetry/instrumentation` — auto-instrumentation runtime
- `@opentelemetry/instrumentation-fetch` — `fetch` + W3C `traceparent` propagation
- `@opentelemetry/instrumentation-document-load` — initial-paint timings
- `@opentelemetry/instrumentation-user-interaction` — click / keypress / submit

No `@opentelemetry/context-zone`: the workspace is zoneless per ADR-0004; the default `StackContextManager` covers the auto-instrumented paths. Custom spans across `await` will need explicit `context.with(...)` plumbing — fine, encountered as code lands.

**Code**:

- [`apps/portal-shell/src/observability/tracing.ts`](apps/portal-shell/src/observability/tracing.ts) — `WebTracerProvider` bootstrap. Documents the load-order constraint inline (same pattern as the BFF: must be the very first import of `main.ts`, otherwise auto-instrumentations miss everything imported above).
- `apps/portal-shell/src/main.ts` now imports the tracing module as line 1.

**CORS plumbing** for end-to-end trace propagation:

- BFF (`apps/portal-bff/src/main.ts`) calls `enableCors` with a minimal dev allowlist (`http://localhost:4200`) and explicit permission for the W3C `traceparent` / `tracestate` headers. The full security-grade CORS (per-environment allowlists, helmet, cookie-session, CSRF) belongs to the future phase-2 security ADR — this PR adds the strict minimum for the SPA→BFF trace context to survive cross-origin pre-flight.
- OTel Collector (`infra/local/otel-collector.yaml`) gains a `cors` block on its OTLP/HTTP receiver so the browser's own OTLP POST clears its pre-flight.

**ADR-0012 §Confirmation** rewritten: a new "Wired in the SPA foundation PR (phase 2)" block enumerates what landed here; the carry-over "Wired as features land" list drops the SPA-side SDK item and adds a follow-up note about the security-grade CORS.

## Verification

```bash
pnpm exec nx run-many -t lint test build           # 8 projects green
pnpm audit                                          # 0 vulns
./infra/local/dev.sh up observability               # bring up Collector + Jaeger
./infra/local/dev.sh                                # (separately, BFF stack — your choice)
pnpm nx serve portal-bff                           # localhost:3000
pnpm nx serve portal-shell                         # localhost:4200
```

Open http://localhost:4200 → a `document_load` trace appears in http://localhost:16686 with `service.name=portal-shell`. From DevTools, run `fetch('http://localhost:3000/api/health').then(r => r.json())` → a fetch span appears with a child BFF span on the same trace.

## Test plan

- [ ] CI green on this PR.
- [ ] After local up, `document_load` span visible in Jaeger UI for the SPA.
- [ ] Cross-origin fetch from SPA carries `traceparent` (visible in Network tab) and produces a single end-to-end trace SPA → BFF in Jaeger.
- [ ] DevTools console shows no CORS warnings about `traceparent`, `tracestate`, or the `localhost:4318/v1/traces` POST.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #72
This commit was merged in pull request #72.
This commit is contained in:
2026-05-09 23:23:18 +02:00
parent c3c15585ff
commit 8f2cd4e068
7 changed files with 255 additions and 36 deletions
+21 -2
View File
@@ -21,6 +21,24 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
// CORS — minimal dev-time allowlist. The SPA running on
// http://localhost:4200 issues fetches to the BFF and must be
// able to send the W3C `traceparent` (and `tracestate`) headers
// that `@opentelemetry/instrumentation-fetch` injects, so the BFF
// can pick up the parent span id and emit child spans on the same
// trace. The full security-grade allowlist (per-environment
// origins, credentials policy, helmet stack, etc.) lands with the
// phase-2 security ADR — for now this is the minimum needed for
// end-to-end tracing.
app.enableCors({
origin: (process.env['CORS_ALLOWED_ORIGINS'] ?? 'http://localhost:4200')
.split(',')
.map((o) => o.trim())
.filter(Boolean),
allowedHeaders: ['Content-Type', 'Accept', 'Authorization', 'traceparent', 'tracestate'],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
@@ -29,8 +47,9 @@ async function bootstrap() {
}),
);
// Phase-2 security ADRs will add: helmet, CORS allowlist, cookie-session,
// CSRF protection, rate limiting, auth guards, structured error filter.
// Phase-2 security ADRs will harden the above: helmet, real CORS
// allowlist, cookie-session, CSRF protection, rate limiting, auth
// guards, structured error filter.
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
+6
View File
@@ -1,3 +1,9 @@
// MUST be the very first import — see apps/portal-shell/src/observability/tracing.ts
// for the reasoning. The auto-instrumentations patch `fetch`,
// `XMLHttpRequest`, and `addEventListener` at module-load time;
// anything imported above this line escapes that patching.
import './observability/tracing';
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
@@ -0,0 +1,81 @@
/**
* OpenTelemetry SDK bootstrap for the SPA (per ADR-0012, phase 2).
*
* Symmetrical to apps/portal-bff/src/observability/tracing.ts — must
* be the very first import in `main.ts` so the auto-instrumentations
* patch `fetch` / `XMLHttpRequest` / `addEventListener` before any
* application code runs.
*
* What this enables out of the box
* ────────────────────────────────
* - `document_load` span on first paint, capturing every Performance
* Timing API metric (DNS, TLS, request, response, DOM events).
* - `fetch` spans for every outgoing request, with the W3C
* `traceparent` header propagated so the BFF picks up the same
* trace id and produces a child span. The result in Jaeger is one
* end-to-end trace SPA → BFF → DB.
* - `user_interaction` spans for click / keypress events on
* instrumented elements.
*
* Notes on the zoneless setup
* ───────────────────────────
* Angular here is zoneless (per ADR-0004), so we deliberately do NOT
* pull in `@opentelemetry/context-zone`. The default
* `StackContextManager` baked into `@opentelemetry/sdk-trace-web` is
* sufficient: the auto-instrumentations capture context at patch
* time and propagate it across the boundaries they own (fetch
* lifecycle, event handlers). Custom spans across `await` will need
* explicit `context.with(...)` plumbing — fine, encountered as code
* lands.
*
* Endpoint configuration
* ──────────────────────
* The OTLP/HTTP endpoint is hard-coded for v1 because env vars do
* not flow into the browser bundle natively (Angular's
* `environment.ts` mechanism is the standard alternative; we'll
* adopt it when prod build needs it). The default targets the
* Collector that ships in `infra/local/dev.compose.yml`. CORS is
* enabled on that Collector receiver — see `otel-collector.yaml`.
*/
import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction';
const SERVICE_NAME = 'portal-shell';
const SERVICE_VERSION = 'dev';
const OTLP_ENDPOINT = 'http://localhost:4318/v1/traces';
const provider = new WebTracerProvider({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: SERVICE_NAME,
[ATTR_SERVICE_VERSION]: SERVICE_VERSION,
}),
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: OTLP_ENDPOINT }))],
});
provider.register();
registerInstrumentations({
instrumentations: [
// Times the initial page load — fires once.
new DocumentLoadInstrumentation(),
// Wraps every `fetch` call. Propagates `traceparent` to the
// listed origins; the BFF dev server is the one we care about
// today. Add prod origins once they exist.
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: [
/^http:\/\/localhost:3000\/.*/, // BFF dev server
],
}),
// Auto-spans on click / keypress / submit (and a small allow-list
// beyond that). Quiet in dev, useful in staging perf
// investigations.
new UserInteractionInstrumentation(),
],
});