# Plan 10 — Instrumentation + Sentry Logging > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add distributed tracing and exception capture to the monorepo with vendor-agnostic interfaces in `core-shared`, full-depth spans (procedure → controller → use-case → repository), throw-site error capture with double-report guard, and hard-coded PII rules across three apps (web-next, cms, web-tanstack). **Architecture:** Two interfaces (`ITracer`, `ILogger`) in `core-shared/instrumentation/` with `Noop`, `Sentry`, and (in `core-testing`) `Recording` implementations. Use-case + controller spans applied via a `withSpan` higher-order wrapper at DI binding time; repository methods emit explicit `tracer.startSpan(...)` calls. Per-app DSNs init Sentry only when env is set; Noop is the default everywhere else (orthogonal to USE_DEV_SEED / NODE_ENV). **Tech Stack:** `@sentry/nextjs` (web-next, cms), `@sentry/node` + `@sentry/vite-plugin` (web-tanstack), inversify (existing), zod (existing), vitest (existing), `eslint-plugin-no-restricted-imports` (boundary rule). **Source spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31–R55). --- ## File structure overview ### Created (core-shared) ``` packages/core-shared/src/instrumentation/ ├── index.ts — public re-exports ├── tracer.interface.ts — ITracer, ISpan, SpanOpts, AttributeValue ├── logger.interface.ts — ILogger, CaptureContext, Breadcrumb ├── noop-tracer.ts — pass-through ITracer ├── noop-tracer.test.ts ├── noop-logger.ts — pass-through ILogger ├── noop-logger.test.ts ├── with-span.ts — higher-order span wrapper ├── with-span.test.ts ├── symbols.ts — TRACER, LOGGER inversify symbols ├── sentry/ │ ├── sentry-tracer.ts │ ├── sentry-tracer.test.ts │ ├── sentry-logger.ts │ ├── sentry-logger.test.ts │ ├── scrub.ts — beforeSend + beforeSendTransaction │ ├── scrub.test.ts │ ├── pii-fields.ts — regex constants │ ├── init-server.ts │ ├── init-server.test.ts │ ├── init-client.ts │ └── init-client.test.ts └── di/ ├── bind-noop-instrumentation.ts ├── bind-noop-instrumentation.test.ts ├── bind-sentry-instrumentation.ts └── bind-sentry-instrumentation.test.ts ``` ### Created (core-testing) ``` packages/core-testing/src/instrumentation/ ├── index.ts ├── recording-tracer.ts — captures startSpan calls ├── recording-tracer.test.ts ├── recording-logger.ts — captures captureException calls └── recording-logger.test.ts ``` ### Modified (core-testing) - `packages/core-testing/src/index.ts` — re-export from `./instrumentation/index.js` - `packages/core-testing/src/setup/vitest.setup.ts` — bind Noop instrumentation by default ### Modified (every feature: blog, auth, marketing-pages, navigation, media) - `packages//src/di/bind-production.ts` — accept `(config, tracer, logger)`, wrap factories with `withSpan` - `packages//src/di/bind-dev-seed.ts` — accept `(tracer, logger)`, wrap factories - `packages//src/di/symbols.ts` — re-export shared TRACER/LOGGER (or bind directly to feature container) - `packages//src/infrastructure/repositories/.repository.ts` — constructor takes `tracer`, `logger`; every method wraps body in `tracer.startSpan(...)` - `packages//src/infrastructure/repositories/.repository.mock.ts` — same constructor signature with Noop defaults; same `startSpan` wrapping - `packages//src/__contracts__/-repository.contract.ts` — assert span emission per method (R50) ### Modified (apps) - `apps/web-next/src/server/bind-production.ts` — `bindAll()` gains Rule 0 (DSN → Sentry vs Noop), threads tracer/logger to every feature binder - `apps/web-next/instrumentation.ts` — NEW - `apps/web-next/instrumentation-client.ts` — NEW - `apps/web-next/next.config.mjs` — wrap with `withSentryConfig` - `apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts` — NEW (R38) - `apps/cms/instrumentation.ts` — NEW - `apps/cms/next.config.mjs` — wrap with `withSentryConfig` - `apps/cms/src/__tests__/sentry-pii-scrubber.test.ts` — NEW (R38) - `apps/web-tanstack/src/instrumentation.ts` — NEW - `apps/web-tanstack/src/instrumentation-client.ts` — NEW - `apps/web-tanstack/vite.config.ts` — add `@sentry/vite-plugin` - `apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts` — NEW (R38) ### Modified (config + tooling) - `turbo.json` — `globalEnv` adds 8 new vars (per spec §4.7) - `packages/core-eslint/base.js` — add `no-restricted-imports` for `@sentry/*` (R40) - `.github/workflows/ci.yml` (or equivalent) — grep step for `sendDefaultPii: true` (R31) ### Modified (docs + HTML) - `docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md` — NEW (R54) - `docs/decisions/adr-014-instrumentation-sentry.md` — NEW (R55) - `CLAUDE.md` — instrumentation conventions - `AGENTS.md` — TRACER/LOGGER symbols, repo span rule, capture rule - `docs/architecture/vertical-feature-spec.md` — add §10 instrumentation - `docs/guides/tdd-workflow.md` — RecordingTracer/Logger usage - `docs/guides/testing-strategy.md` — span/capture assertion patterns - `docs/architecture/dependency-flow.md` — TRACER/LOGGER dataflow - `docs/architecture/data-flow-explainer.html` — new §07 "Tracing & error capture" - `docs/architecture/di-explainer.html` — instrumentation symbols + binders - `packages/core-shared/AGENTS.md` — instrumentation/ subfolder conventions --- ## Task index - **Phase A — Foundation:** Tasks 1–6 (scaffold, interfaces, Noops, withSpan, symbols) - **Phase B — Sentry adapters:** Tasks 7–11 (SentryTracer, SentryLogger, scrubbers, init helpers) - **Phase C — DI binders:** Tasks 12–14 (bindNoop, bindSentry, bindAll dispatcher) - **Phase D — Test infra:** Tasks 15–17 (RecordingTracer, RecordingLogger, vitest setup) - **Phase E — Per-feature wiring:** Tasks 18–22 (blog pilot + auth + marketing-pages + navigation + media) - **Phase F — Contract + factory upgrades:** Tasks 23–24 (defineContractSuite expectSpan, contract suite updates) - **Phase G — App integration:** Tasks 25–27 (web-next, cms, web-tanstack) - **Phase H — Boundary + config:** Tasks 28–29 (ESLint rule + CI grep + turbo.json) - **Phase I — Docs + HTML:** Tasks 30–33 (refactor-log/ADR final, docs pass, HTML updates) --- (Tasks below — each TDD'd, single-commit, code-complete.) --- ## Phase A — Foundation ### Task 1: Scaffold refactor log + ADR-014 stub **Files:** - Create: `docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md` - Create: `docs/decisions/adr-014-instrumentation-sentry.md` - [ ] **Step 1: Write the refactor log** ```markdown # Refactor Log — Instrumentation + Sentry Logging (Plan 10) **Date:** 2026-05-06 **Spec:** docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md **Plan:** docs/superpowers/plans/2026-05-06-plan-10-instrumentation-sentry.md **Branch:** feature/instrumentation-sentry ## Tasks - [ ] Task 1 — Scaffold refactor log + ADR-014 stub - [ ] Task 2 — Tracer interface + ISpan + AttributeValue + SpanOpts - [ ] Task 3 — NoopTracer - [ ] Task 4 — Logger interface + NoopLogger + Breadcrumb + CaptureContext - [ ] Task 5 — withSpan helper - [ ] Task 6 — Symbols + index barrel - [ ] Task 7 — SentryTracer adapter - [ ] Task 8 — SentryLogger adapter (with double-report guard) - [ ] Task 9 — pii-fields constants + scrub.beforeSend / scrub.beforeSendTransaction - [ ] Task 10 — init-server helper - [ ] Task 11 — init-client helper (browser-only) - [ ] Task 12 — bindNoopInstrumentation + bindSentryInstrumentation - [ ] Task 13 — apps/web-next bindAll() Rule 0 dispatcher - [ ] Task 14 — Tests for bindAll() orthogonality (R47) - [ ] Task 15 — RecordingTracer in core-testing - [ ] Task 16 — RecordingLogger in core-testing - [ ] Task 17 — vitest.setup.ts binds Noop by default - [ ] Task 18 — Blog feature wiring (pilot) - [ ] Task 19 — Auth feature wiring - [ ] Task 20 — Marketing-pages feature wiring - [ ] Task 21 — Navigation feature wiring - [ ] Task 22 — Media feature wiring - [ ] Task 23 — defineContractSuite expectSpan helper - [ ] Task 24 — Update repo contract suites to assert span shape - [ ] Task 25 — apps/web-next instrumentation files + scrubber test - [ ] Task 26 — apps/cms instrumentation files + scrubber test - [ ] Task 27 — apps/web-tanstack instrumentation files + scrubber test - [ ] Task 28 — ESLint boundary rule (R40) + CI grep gate (R31) - [ ] Task 29 — turbo.json globalEnv updates - [ ] Task 30 — Doc updates (CLAUDE.md, AGENTS.md, vertical-feature-spec.md) - [ ] Task 31 — Doc updates (tdd-workflow.md, testing-strategy.md, dependency-flow.md, core-shared/AGENTS.md) - [ ] Task 32 — HTML updates (data-flow-explainer §07, di-explainer additions) - [ ] Task 33 — ADR-014 final + refactor log final ## Decisions deviated from spec (populate as work progresses) ## Notable surprises (populate as work progresses) ``` - [ ] **Step 2: Write the ADR-014 stub** ```markdown # ADR-014 — Instrumentation & Sentry Logging **Status:** Proposed (will be Accepted on Plan 10 completion) **Date:** 2026-05-06 **Spec:** docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md ## Context (stub — finalized in Task 33) ## Decision (stub — finalized in Task 33) ## Consequences (stub — finalized in Task 33) ``` - [ ] **Step 3: Commit** ```bash git add docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md \ docs/decisions/adr-014-instrumentation-sentry.md git commit -m "chore(plan-10): scaffold refactor log + ADR-014 stub" ``` --- ### Task 2: Tracer interface + ISpan + types **Files:** - Create: `packages/core-shared/src/instrumentation/tracer.interface.ts` - (Tests come in Task 3 — interface alone has nothing to test.) - [ ] **Step 1: Write the file** ```ts // packages/core-shared/src/instrumentation/tracer.interface.ts export type AttributeValue = string | number | boolean | null; export type SpanOpts = { name: string; op?: "use-case" | "controller" | "repository" | "service" | string; attributes?: Record; }; export interface ISpan { setAttribute(key: string, value: AttributeValue): void; setStatus(status: "ok" | "error", message?: string): void; } export interface ITracer { startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise; } ``` - [ ] **Step 2: Verify it compiles** Run: `pnpm --filter @repo/core-shared build` Expected: build succeeds; new file present in `dist/`. - [ ] **Step 3: Commit** ```bash git add packages/core-shared/src/instrumentation/tracer.interface.ts git commit -m "feat(core-shared): add ITracer/ISpan interfaces" ``` --- ### Task 3: NoopTracer **Files:** - Create: `packages/core-shared/src/instrumentation/noop-tracer.ts` - Create: `packages/core-shared/src/instrumentation/noop-tracer.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core-shared/src/instrumentation/noop-tracer.test.ts import { describe, it, expect, vi } from "vitest"; import { NoopTracer } from "@/instrumentation/noop-tracer"; import type { ISpan } from "@/instrumentation/tracer.interface"; describe("NoopTracer", () => { it("startSpan returns the function result", async () => { const tracer = new NoopTracer(); const result = await tracer.startSpan({ name: "test.op" }, async () => 42); expect(result).toBe(42); }); it("startSpan passes a no-op ISpan to the function", async () => { const tracer = new NoopTracer(); let received: ISpan | undefined; await tracer.startSpan({ name: "test.op" }, async (span) => { received = span; return undefined; }); expect(received).toBeDefined(); // setAttribute and setStatus must be callable without throwing expect(() => received!.setAttribute("k", "v")).not.toThrow(); expect(() => received!.setStatus("ok")).not.toThrow(); expect(() => received!.setStatus("error", "msg")).not.toThrow(); }); it("propagates exceptions from the wrapped function", async () => { const tracer = new NoopTracer(); const err = new Error("boom"); await expect( tracer.startSpan({ name: "test.op" }, async () => { throw err; }), ).rejects.toBe(err); }); it("does not invoke external services", async () => { const tracer = new NoopTracer(); const fn = vi.fn(async () => "ok"); await tracer.startSpan({ name: "test.op" }, fn); expect(fn).toHaveBeenCalledTimes(1); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test noop-tracer` Expected: FAIL — `NoopTracer` not found. - [ ] **Step 3: Implement NoopTracer** ```ts // packages/core-shared/src/instrumentation/noop-tracer.ts import type { ITracer, ISpan, SpanOpts } from "./tracer.interface"; const NOOP_SPAN: ISpan = { setAttribute: () => {}, setStatus: () => {}, }; export class NoopTracer implements ITracer { async startSpan(_opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { return fn(NOOP_SPAN); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test noop-tracer` Expected: PASS — 4 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-shared/src/instrumentation/noop-tracer.ts \ packages/core-shared/src/instrumentation/noop-tracer.test.ts git commit -m "feat(core-shared): add NoopTracer" ``` --- ### Task 4: Logger interface + NoopLogger **Files:** - Create: `packages/core-shared/src/instrumentation/logger.interface.ts` - Create: `packages/core-shared/src/instrumentation/noop-logger.ts` - Create: `packages/core-shared/src/instrumentation/noop-logger.test.ts` - [ ] **Step 1: Write the interface** ```ts // packages/core-shared/src/instrumentation/logger.interface.ts export type Breadcrumb = { category: string; message: string; level?: "info" | "warning" | "error"; data?: Record; }; export type CaptureContext = { tags?: Record; extras?: Record; fingerprint?: string[]; }; export interface ILogger { captureException(err: unknown, ctx?: CaptureContext): void; captureMessage( msg: string, level?: "info" | "warning" | "error", ctx?: CaptureContext, ): void; addBreadcrumb(b: Breadcrumb): void; setUser(user: { id: string } | null): void; } ``` - [ ] **Step 2: Write the failing test** ```ts // packages/core-shared/src/instrumentation/noop-logger.test.ts import { describe, it, expect } from "vitest"; import { NoopLogger } from "@/instrumentation/noop-logger"; describe("NoopLogger", () => { it("captureException is callable with err and ctx", () => { const logger = new NoopLogger(); expect(() => logger.captureException(new Error("x"))).not.toThrow(); expect(() => logger.captureException(new Error("x"), { tags: { feature: "blog" } }), ).not.toThrow(); }); it("captureMessage is callable", () => { const logger = new NoopLogger(); expect(() => logger.captureMessage("hello")).not.toThrow(); expect(() => logger.captureMessage("hello", "warning")).not.toThrow(); expect(() => logger.captureMessage("hello", "error", { extras: { foo: 1 } }), ).not.toThrow(); }); it("addBreadcrumb is callable", () => { const logger = new NoopLogger(); expect(() => logger.addBreadcrumb({ category: "test", message: "x" }), ).not.toThrow(); }); it("setUser accepts opaque id and null", () => { const logger = new NoopLogger(); expect(() => logger.setUser({ id: "u1" })).not.toThrow(); expect(() => logger.setUser(null)).not.toThrow(); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test noop-logger` Expected: FAIL — `NoopLogger` not found. - [ ] **Step 4: Implement NoopLogger** ```ts // packages/core-shared/src/instrumentation/noop-logger.ts import type { ILogger, Breadcrumb, CaptureContext } from "./logger.interface"; export class NoopLogger implements ILogger { captureException(_err: unknown, _ctx?: CaptureContext): void {} captureMessage( _msg: string, _level?: "info" | "warning" | "error", _ctx?: CaptureContext, ): void {} addBreadcrumb(_b: Breadcrumb): void {} setUser(_user: { id: string } | null): void {} } ``` - [ ] **Step 5: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test noop-logger` Expected: PASS — 4 tests. - [ ] **Step 6: Commit** ```bash git add packages/core-shared/src/instrumentation/logger.interface.ts \ packages/core-shared/src/instrumentation/noop-logger.ts \ packages/core-shared/src/instrumentation/noop-logger.test.ts git commit -m "feat(core-shared): add ILogger interface + NoopLogger" ``` --- ### Task 5: `withSpan` helper **Files:** - Create: `packages/core-shared/src/instrumentation/with-span.ts` - Create: `packages/core-shared/src/instrumentation/with-span.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core-shared/src/instrumentation/with-span.test.ts import { describe, it, expect, vi } from "vitest"; import { withSpan } from "@/instrumentation/with-span"; import type { ITracer, ISpan, SpanOpts } from "@/instrumentation/tracer.interface"; function makeRecordingTracer() { const calls: SpanOpts[] = []; const tracer: ITracer = { startSpan: vi.fn(async (opts, fn) => { calls.push(opts); const span: ISpan = { setAttribute: () => {}, setStatus: () => {} }; return fn(span); }), }; return { tracer, calls }; } describe("withSpan", () => { it("wraps fn with a span using static opts", async () => { const { tracer, calls } = makeRecordingTracer(); const fn = async (a: number, b: number) => a + b; const wrapped = withSpan(tracer, { name: "test.add", op: "use-case" }, fn); const result = await wrapped(2, 3); expect(result).toBe(5); expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ name: "test.add", op: "use-case" }); }); it("wraps fn with span opts derived from args (function form)", async () => { const { tracer, calls } = makeRecordingTracer(); const fn = async (id: string) => `result-${id}`; const wrapped = withSpan( tracer, ([id]) => ({ name: "test.byId", op: "repository", attributes: { id } }), fn, ); const result = await wrapped("abc"); expect(result).toBe("result-abc"); expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ name: "test.byId", op: "repository", attributes: { id: "abc" }, }); }); it("propagates errors thrown by fn", async () => { const { tracer } = makeRecordingTracer(); const wrapped = withSpan(tracer, { name: "test.err" }, async () => { throw new Error("boom"); }); await expect(wrapped()).rejects.toThrow("boom"); }); it("preserves identity across multiple invocations (closure stable)", async () => { const { tracer, calls } = makeRecordingTracer(); const wrapped = withSpan(tracer, { name: "test.same" }, async (n: number) => n); await wrapped(1); await wrapped(2); expect(calls).toHaveLength(2); expect(calls.every((c) => c.name === "test.same")).toBe(true); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test with-span` Expected: FAIL — `withSpan` not found. - [ ] **Step 3: Implement withSpan** ```ts // packages/core-shared/src/instrumentation/with-span.ts import type { ITracer, SpanOpts } from "./tracer.interface"; export function withSpan( tracer: ITracer, opts: SpanOpts | ((args: Args) => SpanOpts), fn: (...args: Args) => Promise, ): (...args: Args) => Promise { return (...args) => { const resolved = typeof opts === "function" ? opts(args) : opts; return tracer.startSpan(resolved, () => fn(...args)); }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test with-span` Expected: PASS — 4 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-shared/src/instrumentation/with-span.ts \ packages/core-shared/src/instrumentation/with-span.test.ts git commit -m "feat(core-shared): add withSpan higher-order helper" ``` --- ### Task 6: Symbols + index barrel **Files:** - Create: `packages/core-shared/src/instrumentation/symbols.ts` - Create: `packages/core-shared/src/instrumentation/index.ts` - Modify: `packages/core-shared/src/index.ts` (re-export instrumentation) - Modify: `packages/core-shared/package.json` (add `./instrumentation` subpath if not implicit) - [ ] **Step 1: Write the symbols file** ```ts // packages/core-shared/src/instrumentation/symbols.ts export const INSTRUMENTATION_SYMBOLS = { TRACER: Symbol.for("core-shared.TRACER"), LOGGER: Symbol.for("core-shared.LOGGER"), } as const; ``` - [ ] **Step 2: Write the index barrel** ```ts // packages/core-shared/src/instrumentation/index.ts export type { ITracer, ISpan, SpanOpts, AttributeValue, } from "./tracer.interface"; export type { ILogger, Breadcrumb, CaptureContext, } from "./logger.interface"; export { NoopTracer } from "./noop-tracer"; export { NoopLogger } from "./noop-logger"; export { withSpan } from "./with-span"; export { INSTRUMENTATION_SYMBOLS } from "./symbols"; ``` - [ ] **Step 3: Re-export from package root** ```ts // packages/core-shared/src/index.ts (append at bottom; preserve existing exports) export * from "./instrumentation/index"; ``` - [ ] **Step 4: Add subpath export in package.json** Open `packages/core-shared/package.json` and ensure the `exports` field includes: ```json { "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }, "./instrumentation": { "import": "./dist/instrumentation/index.js", "types": "./dist/instrumentation/index.d.ts" }, "./trpc/define-error-middleware": { "import": "./dist/trpc/define-error-middleware.js", "types": "./dist/trpc/define-error-middleware.d.ts" } } } ``` (Preserve any existing entries — the example shows the *additions*. If the current file uses different export structure, integrate accordingly.) - [ ] **Step 5: Build to verify** Run: `pnpm --filter @repo/core-shared build` Expected: build succeeds. - [ ] **Step 6: Verify subpath import works** Create a one-off check: ```bash cat <<'EOF' > /tmp/check-instrumentation.ts import { NoopTracer, NoopLogger, withSpan, INSTRUMENTATION_SYMBOLS } from "@repo/core-shared/instrumentation"; console.log(typeof NoopTracer, typeof NoopLogger, typeof withSpan, INSTRUMENTATION_SYMBOLS.TRACER); EOF cd packages/core-shared && pnpm tsc --noEmit /tmp/check-instrumentation.ts ``` Expected: no errors. (Delete `/tmp/check-instrumentation.ts` after.) - [ ] **Step 7: Commit** ```bash git add packages/core-shared/src/instrumentation/symbols.ts \ packages/core-shared/src/instrumentation/index.ts \ packages/core-shared/src/index.ts \ packages/core-shared/package.json git commit -m "feat(core-shared): symbols + barrel for instrumentation subpath" ``` --- ## Phase B — Sentry adapters > **Prerequisite:** Add `@sentry/nextjs` to `packages/core-shared/package.json` dependencies before starting Task 7. The adapter files import from it. ```bash pnpm --filter @repo/core-shared add @sentry/nextjs ``` ### Task 7: SentryTracer adapter **Files:** - Create: `packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts` - Create: `packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@sentry/nextjs", () => ({ startSpan: vi.fn((_opts, fn) => fn({ setAttribute: vi.fn(), setStatus: vi.fn() })), })); import * as Sentry from "@sentry/nextjs"; import { SentryTracer } from "@/instrumentation/sentry/sentry-tracer"; describe("SentryTracer", () => { beforeEach(() => { vi.clearAllMocks(); }); it("delegates startSpan to @sentry/nextjs.startSpan", async () => { const tracer = new SentryTracer(); const result = await tracer.startSpan( { name: "blog.getArticles", op: "use-case" }, async () => "value", ); expect(result).toBe("value"); expect(Sentry.startSpan).toHaveBeenCalledTimes(1); expect((Sentry.startSpan as any).mock.calls[0][0]).toMatchObject({ name: "blog.getArticles", op: "use-case", }); }); it("forwards attributes to Sentry", async () => { const tracer = new SentryTracer(); await tracer.startSpan( { name: "articles.findAll", op: "repository", attributes: { collection: "articles", limit: 10 } }, async () => undefined, ); expect((Sentry.startSpan as any).mock.calls[0][0].attributes).toEqual({ collection: "articles", limit: 10, }); }); it("propagates errors from the wrapped function", async () => { const tracer = new SentryTracer(); await expect( tracer.startSpan({ name: "x" }, async () => { throw new Error("boom"); }), ).rejects.toThrow("boom"); }); it("ISpan adapter forwards setAttribute and setStatus to Sentry's span", async () => { const sentrySpan = { setAttribute: vi.fn(), setStatus: vi.fn() }; (Sentry.startSpan as any).mockImplementationOnce((_opts: unknown, fn: any) => fn(sentrySpan)); const tracer = new SentryTracer(); await tracer.startSpan({ name: "x" }, async (span) => { span.setAttribute("k", "v"); span.setStatus("error", "msg"); return undefined; }); expect(sentrySpan.setAttribute).toHaveBeenCalledWith("k", "v"); // Sentry uses status code 2 for error, 1 for ok — but our adapter passes through the string expect(sentrySpan.setStatus).toHaveBeenCalled(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test sentry-tracer` Expected: FAIL — `SentryTracer` not found. - [ ] **Step 3: Implement SentryTracer** ```ts // packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts import * as Sentry from "@sentry/nextjs"; import type { ITracer, ISpan, SpanOpts, } from "../tracer.interface"; export class SentryTracer implements ITracer { async startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { return Sentry.startSpan( { name: opts.name, op: opts.op, attributes: opts.attributes, }, async (sentrySpan) => { const adapter: ISpan = { setAttribute(key, value) { sentrySpan?.setAttribute?.(key, value); }, setStatus(status, message) { // Sentry v8+ uses { code: number, message?: string }; we map our enum const code = status === "ok" ? 1 : 2; sentrySpan?.setStatus?.({ code, message }); }, }; return fn(adapter); }, ); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test sentry-tracer` Expected: PASS — 4 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts \ packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts \ packages/core-shared/package.json packages/core-shared/pnpm-lock.yaml # (pnpm-lock if separate; otherwise root lockfile) git commit -m "feat(core-shared): SentryTracer adapter" ``` --- ### Task 8: SentryLogger adapter (with double-report guard) **Files:** - Create: `packages/core-shared/src/instrumentation/sentry/sentry-logger.ts` - Create: `packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn(), captureMessage: vi.fn(), addBreadcrumb: vi.fn(), setUser: vi.fn(), })); import * as Sentry from "@sentry/nextjs"; import { SentryLogger } from "@/instrumentation/sentry/sentry-logger"; describe("SentryLogger", () => { beforeEach(() => { vi.clearAllMocks(); }); it("captureException forwards to Sentry on first call", () => { const logger = new SentryLogger(); const err = new Error("boom"); logger.captureException(err, { tags: { feature: "blog" } }); expect(Sentry.captureException).toHaveBeenCalledTimes(1); expect((Sentry.captureException as any).mock.calls[0][0]).toBe(err); }); it("captureException is a no-op when err already marked __sentryReported", () => { const logger = new SentryLogger(); const err = new Error("already-reported"); Object.defineProperty(err, "__sentryReported", { value: true }); logger.captureException(err); expect(Sentry.captureException).not.toHaveBeenCalled(); }); it("captureException marks err as __sentryReported after sending", () => { const logger = new SentryLogger(); const err = new Error("once"); logger.captureException(err); expect((err as unknown as { __sentryReported: boolean }).__sentryReported).toBe(true); // Second call: no-op logger.captureException(err); expect(Sentry.captureException).toHaveBeenCalledTimes(1); }); it("__sentryReported is non-enumerable", () => { const logger = new SentryLogger(); const err = new Error("x"); logger.captureException(err); expect(Object.keys(err)).not.toContain("__sentryReported"); expect(JSON.stringify(err)).not.toContain("__sentryReported"); }); it("captureMessage forwards to Sentry", () => { const logger = new SentryLogger(); logger.captureMessage("hello", "warning", { tags: { foo: "bar" } }); expect(Sentry.captureMessage).toHaveBeenCalledWith("hello", expect.objectContaining({ level: "warning" })); }); it("addBreadcrumb forwards to Sentry", () => { const logger = new SentryLogger(); logger.addBreadcrumb({ category: "test", message: "x", data: { k: "v" } }); expect(Sentry.addBreadcrumb).toHaveBeenCalledTimes(1); }); it("setUser strips non-id keys and warns in dev", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const logger = new SentryLogger(); logger.setUser({ id: "u1", email: "a@b.c", username: "alice" } as any); expect(Sentry.setUser).toHaveBeenCalledWith({ id: "u1" }); expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); it("setUser passes null through", () => { const logger = new SentryLogger(); logger.setUser(null); expect(Sentry.setUser).toHaveBeenCalledWith(null); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test sentry-logger` Expected: FAIL — `SentryLogger` not found. - [ ] **Step 3: Implement SentryLogger** ```ts // packages/core-shared/src/instrumentation/sentry/sentry-logger.ts import * as Sentry from "@sentry/nextjs"; import type { ILogger, Breadcrumb, CaptureContext, } from "../logger.interface"; const REPORTED = "__sentryReported" as const; function isReported(err: unknown): boolean { return ( err !== null && typeof err === "object" && Boolean((err as Record)[REPORTED]) ); } function markReported(err: unknown): void { if (err !== null && typeof err === "object") { Object.defineProperty(err, REPORTED, { value: true, enumerable: false, configurable: false, writable: false, }); } } export class SentryLogger implements ILogger { captureException(err: unknown, ctx?: CaptureContext): void { if (isReported(err)) return; Sentry.captureException(err, ctx); markReported(err); } captureMessage( msg: string, level: "info" | "warning" | "error" = "info", ctx?: CaptureContext, ): void { Sentry.captureMessage(msg, { level, ...ctx }); } addBreadcrumb(b: Breadcrumb): void { Sentry.addBreadcrumb({ category: b.category, message: b.message, level: b.level, data: b.data, }); } setUser(user: { id: string } | null): void { if (user === null) { Sentry.setUser(null); return; } const { id, ...extra } = user as { id: string } & Record; if (Object.keys(extra).length > 0) { // R36 — strip non-id keys; warn in dev for visibility console.warn( "[SentryLogger.setUser] stripped non-id keys for PII safety:", Object.keys(extra), ); } Sentry.setUser({ id }); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test sentry-logger` Expected: PASS — 8 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-shared/src/instrumentation/sentry/sentry-logger.ts \ packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts git commit -m "feat(core-shared): SentryLogger with double-report guard + R36 user-context strip" ``` --- ### Task 9: PII fields + scrubbers (R32, R33) **Files:** - Create: `packages/core-shared/src/instrumentation/sentry/pii-fields.ts` - Create: `packages/core-shared/src/instrumentation/sentry/scrub.ts` - Create: `packages/core-shared/src/instrumentation/sentry/scrub.test.ts` - [ ] **Step 1: Write the constants** ```ts // packages/core-shared/src/instrumentation/sentry/pii-fields.ts // R32 — substring match on event keys (case-insensitive) export const PII_KEY_SUBSTRINGS = [ "email", "password", "token", "cookie", "authorization", "set-cookie", "x-api-key", "apikey", "api_key", "secret", ] as const; // R33 — substring match on URL query-param keys (case-insensitive) export const PII_QUERY_PARAM_SUBSTRINGS = [ "token", "email", "password", "key", "sig", "signature", "access_token", "accesstoken", "secret", ] as const; export const REDACTED_VALUE = "[redacted]" as const; export const REDACTED_IP = "[redacted-ip]" as const; // IPv4: simple dotted-quad; IPv6: any colon-separated hex with at least one :: export const IPV4_REGEX = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g; export const IPV6_REGEX = /\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}/g; export function keyContainsPii(key: string): boolean { const lower = key.toLowerCase(); return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s)); } export function queryParamContainsPii(key: string): boolean { const lower = key.toLowerCase(); return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s)); } ``` - [ ] **Step 2: Write the failing test** ```ts // packages/core-shared/src/instrumentation/sentry/scrub.test.ts import { describe, it, expect } from "vitest"; import { beforeSend, beforeSendTransaction } from "@/instrumentation/sentry/scrub"; describe("beforeSend", () => { it("redacts top-level keys whose names contain PII substrings", () => { const event = { extra: { email: "a@b.c", username: "alice" }, contexts: { custom: { password: "p", note: "ok" } }, } as any; const result = beforeSend(event, {} as any) as any; expect(result.extra.email).toBe("[redacted]"); expect(result.extra.username).toBe("alice"); expect(result.contexts.custom.password).toBe("[redacted]"); expect(result.contexts.custom.note).toBe("ok"); }); it("redacts derived key names (substring match): userEmail, accessToken, apiKey", () => { const event = { extra: { userEmail: "a@b.c", accessToken: "t", apiKey: "k", id: "u1" }, } as any; const result = beforeSend(event, {} as any) as any; expect(result.extra.userEmail).toBe("[redacted]"); expect(result.extra.accessToken).toBe("[redacted]"); expect(result.extra.apiKey).toBe("[redacted]"); expect(result.extra.id).toBe("u1"); }); it("redacts headers map keys case-insensitively", () => { const event = { request: { headers: { Authorization: "Bearer x", "Set-Cookie": "session=abc", "User-Agent": "ua" }, }, } as any; const result = beforeSend(event, {} as any) as any; expect(result.request.headers.Authorization).toBe("[redacted]"); expect(result.request.headers["Set-Cookie"]).toBe("[redacted]"); expect(result.request.headers["User-Agent"]).toBe("ua"); }); it("redacts IPv4 addresses found in string values", () => { const event = { extra: { note: "Connection from 192.168.1.10 failed" } } as any; const result = beforeSend(event, {} as any) as any; expect(result.extra.note).toBe("Connection from [redacted-ip] failed"); }); it("redacts IPv6 addresses found in string values", () => { const event = { extra: { note: "Tunnel to fe80::1ff:fe23:4567:890a established" } } as any; const result = beforeSend(event, {} as any) as any; expect(result.extra.note).toContain("[redacted-ip]"); }); it("does not crash on null/undefined branches", () => { expect(beforeSend({ extra: null } as any, {} as any)).toBeTruthy(); expect(beforeSend({} as any, {} as any)).toBeTruthy(); }); it("returns the event (not null) — keeps Sentry transport flowing", () => { expect(beforeSend({ extra: { ok: true } } as any, {} as any)).toBeTruthy(); }); }); describe("beforeSendTransaction", () => { it("strips PII query params from request.url", () => { const event = { request: { url: "https://app/api/foo?token=secret&user=alice&email=a@b.c" }, } as any; const result = beforeSendTransaction(event, {} as any) as any; expect(result.request.url).toContain("token=%5Bredacted%5D"); expect(result.request.url).toContain("email=%5Bredacted%5D"); expect(result.request.url).toContain("user=alice"); }); it("strips PII query params from event.transaction", () => { const event = { transaction: "/foo?token=x&id=y" } as any; const result = beforeSendTransaction(event, {} as any) as any; expect(result.transaction).toContain("token=%5Bredacted%5D"); expect(result.transaction).toContain("id=y"); }); it("matches derived param names (accessToken, ApiSecret)", () => { const event = { request: { url: "https://x/y?accessToken=t&ApiSecret=z&safe=1" } } as any; const result = beforeSendTransaction(event, {} as any) as any; expect(result.request.url).toContain("accessToken=%5Bredacted%5D"); expect(result.request.url).toContain("ApiSecret=%5Bredacted%5D"); expect(result.request.url).toContain("safe=1"); }); it("returns the event when no URL present", () => { expect(beforeSendTransaction({} as any, {} as any)).toBeTruthy(); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test scrub` Expected: FAIL — `beforeSend`/`beforeSendTransaction` not found. - [ ] **Step 4: Implement scrub** ```ts // packages/core-shared/src/instrumentation/sentry/scrub.ts import type { ErrorEvent, EventHint, TransactionEvent } from "@sentry/nextjs"; import { IPV4_REGEX, IPV6_REGEX, REDACTED_IP, REDACTED_VALUE, keyContainsPii, queryParamContainsPii, } from "./pii-fields"; function redactString(s: string): string { return s.replace(IPV4_REGEX, REDACTED_IP).replace(IPV6_REGEX, REDACTED_IP); } function deepScrub(value: unknown, parentKey = ""): unknown { if (value === null || value === undefined) return value; if (typeof value === "string") { return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value); } if (typeof value === "number" || typeof value === "boolean") { return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value; } if (Array.isArray(value)) { return value.map((v) => deepScrub(v, parentKey)); } if (typeof value === "object") { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = keyContainsPii(k) ? REDACTED_VALUE : deepScrub(v, k); } return out; } return value; } export function beforeSend(event: ErrorEvent, _hint: EventHint): ErrorEvent | null { return deepScrub(event) as ErrorEvent; } function scrubUrl(url: string): string { try { const u = new URL(url, "http://placeholder.local"); for (const [k] of Array.from(u.searchParams.entries())) { if (queryParamContainsPii(k)) { u.searchParams.set(k, REDACTED_VALUE); } } return url.startsWith("/") ? `${u.pathname}${u.search}` : u.toString(); } catch { return url; } } export function beforeSendTransaction( event: TransactionEvent, _hint: EventHint, ): TransactionEvent | null { const out: TransactionEvent = { ...event }; if (out.request?.url) { out.request = { ...out.request, url: scrubUrl(out.request.url) }; } if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) { out.transaction = scrubUrl(out.transaction); } return out; } ``` - [ ] **Step 5: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test scrub` Expected: PASS — 11 tests. (If a test is flaky on URL encoding, adjust the assertions to use `decodeURIComponent` or compare against the literal `[redacted]`. The encoded form `%5Bredacted%5D` is the URLSearchParams default; either is acceptable as long as the test is consistent.) - [ ] **Step 6: Commit** ```bash git add packages/core-shared/src/instrumentation/sentry/pii-fields.ts \ packages/core-shared/src/instrumentation/sentry/scrub.ts \ packages/core-shared/src/instrumentation/sentry/scrub.test.ts git commit -m "feat(core-shared): PII scrubbers — beforeSend (R32) + beforeSendTransaction (R33)" ``` --- ### Task 10: `init-server` helper **Files:** - Create: `packages/core-shared/src/instrumentation/sentry/init-server.ts` - Create: `packages/core-shared/src/instrumentation/sentry/init-server.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core-shared/src/instrumentation/sentry/init-server.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@sentry/nextjs", () => ({ init: vi.fn(), replayIntegration: vi.fn(() => ({ name: "Replay" })), })); import * as Sentry from "@sentry/nextjs"; import { initSentryServer } from "@/instrumentation/sentry/init-server"; describe("initSentryServer", () => { beforeEach(() => { vi.clearAllMocks(); }); it("calls Sentry.init with sendDefaultPii: false (R31)", () => { initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); expect(Sentry.init).toHaveBeenCalledTimes(1); const call = (Sentry.init as any).mock.calls[0][0]; expect(call.sendDefaultPii).toBe(false); }); it("passes the configured DSN", () => { initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); const call = (Sentry.init as any).mock.calls[0][0]; expect(call.dsn).toBe("https://x@y/1"); }); it("attaches beforeSend + beforeSendTransaction scrubbers", () => { initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); const call = (Sentry.init as any).mock.calls[0][0]; expect(typeof call.beforeSend).toBe("function"); expect(typeof call.beforeSendTransaction).toBe("function"); }); it("uses SENTRY_TRACES_SAMPLE_RATE env when set", () => { const prev = process.env.SENTRY_TRACES_SAMPLE_RATE; process.env.SENTRY_TRACES_SAMPLE_RATE = "0.25"; initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); const call = (Sentry.init as any).mock.calls[0][0]; expect(call.tracesSampleRate).toBe(0.25); if (prev === undefined) delete process.env.SENTRY_TRACES_SAMPLE_RATE; else process.env.SENTRY_TRACES_SAMPLE_RATE = prev; }); it("defaults tracesSampleRate to 1.0 in dev, 0.1 in production", () => { const prevEnv = process.env.NODE_ENV; const prevRate = process.env.SENTRY_TRACES_SAMPLE_RATE; delete process.env.SENTRY_TRACES_SAMPLE_RATE; process.env.NODE_ENV = "development"; initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); expect((Sentry.init as any).mock.calls[0][0].tracesSampleRate).toBe(1.0); (Sentry.init as any).mockClear(); process.env.NODE_ENV = "production"; initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); expect((Sentry.init as any).mock.calls[0][0].tracesSampleRate).toBe(0.1); if (prevEnv === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = prevEnv; if (prevRate !== undefined) process.env.SENTRY_TRACES_SAMPLE_RATE = prevRate; }); it("tags events with the app name", () => { initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); const call = (Sentry.init as any).mock.calls[0][0]; expect(call.initialScope?.tags?.app).toBe("web-next"); }); it("is a no-op when dsn is empty/undefined", () => { initSentryServer({ dsn: "", app: "web-next" }); expect(Sentry.init).not.toHaveBeenCalled(); initSentryServer({ dsn: undefined as any, app: "web-next" }); expect(Sentry.init).not.toHaveBeenCalled(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test init-server` Expected: FAIL — `initSentryServer` not found. - [ ] **Step 3: Implement init-server** ```ts // packages/core-shared/src/instrumentation/sentry/init-server.ts import * as Sentry from "@sentry/nextjs"; import { beforeSend, beforeSendTransaction } from "./scrub"; export type InitServerOpts = { dsn: string | undefined; app: "web-next" | "cms" | "web-tanstack"; release?: string; }; export function initSentryServer(opts: InitServerOpts): void { if (!opts.dsn) return; const isProd = process.env.NODE_ENV === "production"; const tracesSampleRate = process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) : isProd ? 0.1 : 1.0; const environment = process.env.SENTRY_ENVIRONMENT ?? process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development"; const release = opts.release ?? process.env.VERCEL_GIT_COMMIT_SHA ?? "unknown"; Sentry.init({ dsn: opts.dsn, environment, release, tracesSampleRate, sendDefaultPii: false, // R31 — non-negotiable beforeSend, // R32 beforeSendTransaction, // R33 initialScope: { tags: { app: opts.app } }, }); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test init-server` Expected: PASS — 7 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-shared/src/instrumentation/sentry/init-server.ts \ packages/core-shared/src/instrumentation/sentry/init-server.test.ts git commit -m "feat(core-shared): initSentryServer helper (R31, R32, R33, R37 defaults)" ``` --- ### Task 11: `init-client` helper (browser-only) **Files:** - Create: `packages/core-shared/src/instrumentation/sentry/init-client.ts` - Create: `packages/core-shared/src/instrumentation/sentry/init-client.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core-shared/src/instrumentation/sentry/init-client.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts })); vi.mock("@sentry/nextjs", () => ({ init: vi.fn(), replayIntegration, })); import * as Sentry from "@sentry/nextjs"; import { initSentryClient } from "@/instrumentation/sentry/init-client"; describe("initSentryClient", () => { beforeEach(() => { vi.clearAllMocks(); }); it("calls Sentry.init with sendDefaultPii: false (R31)", () => { initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); const call = (Sentry.init as any).mock.calls[0][0]; expect(call.sendDefaultPii).toBe(false); }); it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true (R34, R35)", () => { initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); expect(replayIntegration).toHaveBeenCalledTimes(1); const replayOpts = (replayIntegration as any).mock.calls[0][0]; expect(replayOpts.maskAllText).toBe(true); expect(replayOpts.maskAllInputs).toBe(true); expect(replayOpts.blockAllMedia).toBe(true); }); it("defaults replaysSessionSampleRate to 0.0 (R37)", () => { initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); const call = (Sentry.init as any).mock.calls[0][0]; expect(call.replaysSessionSampleRate).toBe(0.0); }); it("defaults replaysOnErrorSampleRate to 1.0 (R37)", () => { initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); const call = (Sentry.init as any).mock.calls[0][0]; expect(call.replaysOnErrorSampleRate).toBe(1.0); }); it("attaches beforeSend + beforeSendTransaction", () => { initSentryClient({ dsn: "https://x@y/1", app: "web-next" }); const call = (Sentry.init as any).mock.calls[0][0]; expect(typeof call.beforeSend).toBe("function"); expect(typeof call.beforeSendTransaction).toBe("function"); }); it("is a no-op when dsn is empty", () => { initSentryClient({ dsn: "", app: "web-next" }); expect(Sentry.init).not.toHaveBeenCalled(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test init-client` Expected: FAIL — `initSentryClient` not found. - [ ] **Step 3: Implement init-client** ```ts // packages/core-shared/src/instrumentation/sentry/init-client.ts import * as Sentry from "@sentry/nextjs"; import { beforeSend, beforeSendTransaction } from "./scrub"; export type InitClientOpts = { dsn: string | undefined; app: "web-next" | "cms" | "web-tanstack"; release?: string; }; export function initSentryClient(opts: InitClientOpts): void { if (!opts.dsn) return; const isProd = process.env.NODE_ENV === "production"; const tracesSampleRate = process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) : isProd ? 0.1 : 1.0; const environment = process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development"; const release = opts.release ?? "unknown"; Sentry.init({ dsn: opts.dsn, environment, release, tracesSampleRate, sendDefaultPii: false, // R31 beforeSend, // R32 beforeSendTransaction, // R33 replaysSessionSampleRate: 0.0, // R37 — privacy default replaysOnErrorSampleRate: 1.0, // R37 integrations: [ // R34, R35 — mandatory mask flags; allowlist starts empty Sentry.replayIntegration({ maskAllText: true, maskAllInputs: true, blockAllMedia: true, }), ], initialScope: { tags: { app: opts.app } }, }); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test init-client` Expected: PASS — 6 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-shared/src/instrumentation/sentry/init-client.ts \ packages/core-shared/src/instrumentation/sentry/init-client.test.ts git commit -m "feat(core-shared): initSentryClient helper (R34, R35, R37 mandatory replay defaults)" ``` --- ## Phase C — DI binders + dispatcher > **Design note:** The instrumentation symbols (TRACER, LOGGER) live in `core-shared/instrumentation/symbols.ts`. The two binders accept a Container, bind both symbols, and (for `bindSentry`) call `initSentryServer` first. Apps construct `tracer` and `logger` once at boot, then pass them to every feature binder as parameters. This dual approach (container binding *and* parameter passing) keeps internal-resolution code working while letting feature factories receive instances without container lookups. ### Task 12: bindNoopInstrumentation + bindSentryInstrumentation **Files:** - Create: `packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts` - Create: `packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.test.ts` - Create: `packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts` - Create: `packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.test.ts` - Modify: `packages/core-shared/src/instrumentation/index.ts` (re-export the binders) - Modify: `packages/core-shared/package.json` (add `./instrumentation/di/*` subpath if needed; the broad `./instrumentation` export should already cover this) - [ ] **Step 1: Write the failing test for bindNoopInstrumentation** ```ts // packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.test.ts import "reflect-metadata"; import { describe, it, expect } from "vitest"; import { Container } from "inversify"; import { bindNoopInstrumentation } from "@/instrumentation/di/bind-noop-instrumentation"; import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols"; import { NoopTracer } from "@/instrumentation/noop-tracer"; import { NoopLogger } from "@/instrumentation/noop-logger"; import type { ITracer, ILogger } from "@/instrumentation"; describe("bindNoopInstrumentation", () => { it("returns a tracer + logger pair", () => { const c = new Container(); const { tracer, logger } = bindNoopInstrumentation(c); expect(tracer).toBeInstanceOf(NoopTracer); expect(logger).toBeInstanceOf(NoopLogger); }); it("binds TRACER and LOGGER symbols on the container", () => { const c = new Container(); bindNoopInstrumentation(c); const tracer = c.get(INSTRUMENTATION_SYMBOLS.TRACER); const logger = c.get(INSTRUMENTATION_SYMBOLS.LOGGER); expect(tracer).toBeInstanceOf(NoopTracer); expect(logger).toBeInstanceOf(NoopLogger); }); it("is idempotent — second call rebinds the same instances", () => { const c = new Container(); const first = bindNoopInstrumentation(c); const second = bindNoopInstrumentation(c); // Implementations are NoopX, but instances may differ — that's fine expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBe(second.tracer); expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBe(second.logger); expect(first.tracer).toBeInstanceOf(NoopTracer); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test bind-noop-instrumentation` Expected: FAIL — `bindNoopInstrumentation` not found. - [ ] **Step 3: Implement bindNoopInstrumentation** ```ts // packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts import type { Container } from "inversify"; import { NoopTracer } from "../noop-tracer"; import { NoopLogger } from "../noop-logger"; import { INSTRUMENTATION_SYMBOLS } from "../symbols"; import type { ITracer, ILogger } from "../index"; export function bindNoopInstrumentation(container: Container): { tracer: ITracer; logger: ILogger; } { const tracer = new NoopTracer(); const logger = new NoopLogger(); if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { container.unbind(INSTRUMENTATION_SYMBOLS.TRACER); } if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); } container.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); container.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); return { tracer, logger }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test bind-noop-instrumentation` Expected: PASS — 3 tests. - [ ] **Step 5: Write the failing test for bindSentryInstrumentation** ```ts // packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.test.ts import "reflect-metadata"; import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@sentry/nextjs", () => ({ init: vi.fn(), startSpan: vi.fn((_opts, fn) => fn({ setAttribute: vi.fn(), setStatus: vi.fn() })), captureException: vi.fn(), captureMessage: vi.fn(), addBreadcrumb: vi.fn(), setUser: vi.fn(), replayIntegration: vi.fn(() => ({ name: "Replay" })), })); import * as Sentry from "@sentry/nextjs"; import { Container } from "inversify"; import { bindSentryInstrumentation } from "@/instrumentation/di/bind-sentry-instrumentation"; import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols"; import { SentryTracer } from "@/instrumentation/sentry/sentry-tracer"; import { SentryLogger } from "@/instrumentation/sentry/sentry-logger"; describe("bindSentryInstrumentation", () => { beforeEach(() => { vi.clearAllMocks(); }); it("calls Sentry.init via initSentryServer", () => { const c = new Container(); bindSentryInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" }); expect(Sentry.init).toHaveBeenCalledTimes(1); expect((Sentry.init as any).mock.calls[0][0].dsn).toBe("https://x@y/1"); }); it("binds SentryTracer + SentryLogger to the container", () => { const c = new Container(); bindSentryInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" }); expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBeInstanceOf(SentryTracer); expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBeInstanceOf(SentryLogger); }); it("returns the tracer + logger instances", () => { const c = new Container(); const { tracer, logger } = bindSentryInstrumentation(c, { dsn: "https://x@y/1", app: "web-next", }); expect(tracer).toBeInstanceOf(SentryTracer); expect(logger).toBeInstanceOf(SentryLogger); }); }); ``` - [ ] **Step 6: Run test to verify it fails** Run: `pnpm --filter @repo/core-shared test bind-sentry-instrumentation` Expected: FAIL — `bindSentryInstrumentation` not found. - [ ] **Step 7: Implement bindSentryInstrumentation** ```ts // packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts import type { Container } from "inversify"; import { SentryTracer } from "../sentry/sentry-tracer"; import { SentryLogger } from "../sentry/sentry-logger"; import { initSentryServer, type InitServerOpts } from "../sentry/init-server"; import { INSTRUMENTATION_SYMBOLS } from "../symbols"; import type { ITracer, ILogger } from "../index"; export type BindSentryOpts = InitServerOpts; export function bindSentryInstrumentation( container: Container, opts: BindSentryOpts, ): { tracer: ITracer; logger: ILogger } { initSentryServer(opts); const tracer = new SentryTracer(); const logger = new SentryLogger(); if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { container.unbind(INSTRUMENTATION_SYMBOLS.TRACER); } if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); } container.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); container.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); return { tracer, logger }; } ``` - [ ] **Step 8: Run test to verify it passes** Run: `pnpm --filter @repo/core-shared test bind-sentry-instrumentation` Expected: PASS — 3 tests. - [ ] **Step 9: Re-export both binders from instrumentation/index.ts** Append to `packages/core-shared/src/instrumentation/index.ts`: ```ts export { bindNoopInstrumentation } from "./di/bind-noop-instrumentation"; export { bindSentryInstrumentation, type BindSentryOpts, } from "./di/bind-sentry-instrumentation"; ``` - [ ] **Step 10: Build to verify barrel** Run: `pnpm --filter @repo/core-shared build` Expected: build succeeds. - [ ] **Step 11: Commit** ```bash git add packages/core-shared/src/instrumentation/di/ \ packages/core-shared/src/instrumentation/index.ts git commit -m "feat(core-shared): bindNoopInstrumentation + bindSentryInstrumentation" ``` --- ### Task 13: `bindAll()` Rule 0 dispatcher (apps/web-next) **Files:** - Modify: `apps/web-next/src/server/bind-production.ts` > **Note:** Rule 0 wires instrumentation into `bindAll()`, but feature binders haven't been updated yet (those land in Phase E). For now, `bindAll()` constructs tracer + logger and stores them in module-scope variables for downstream binders to read. Per-feature binder signatures change in Phase E to accept tracer/logger as parameters. - [ ] **Step 1: Update bind-production.ts** Replace the contents of `apps/web-next/src/server/bind-production.ts` with the following. The header comment changes; the existing `bindAllProduction` and `bindAllDevSeed` keep their current signatures (we'll wire tracer/logger threading in Phase E). For now, just add the Rule 0 instrumentation step to `bindAll()`: ```ts // apps/web-next/src/server/bind-production.ts // SERVER-ONLY: this module imports Payload config and must never be bundled into the browser. import "reflect-metadata"; import { Container } from "inversify"; import config from "@repo/core-cms"; import { bindNoopInstrumentation, bindSentryInstrumentation, type ITracer, type ILogger, } from "@repo/core-shared/instrumentation"; import { bindProductionBlog } from "@repo/blog/di/bind-production"; import { bindProductionAuth } from "@repo/auth/di/bind-production"; import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production"; import { bindProductionNavigation } from "@repo/navigation/di/bind-production"; import { bindProductionMedia } from "@repo/media/di/bind-production"; import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed"; import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed"; import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed"; import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed"; import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed"; let bound = false; // Shared container holds TRACER + LOGGER bindings; per-feature containers // receive references via parameter passing. This separates the instrumentation // container (one) from feature containers (per-feature, ADR-008). const sharedContainer = new Container(); let resolvedTracer: ITracer | null = null; let resolvedLogger: ILogger | null = null; /** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */ function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } { if (resolvedTracer && resolvedLogger) { return { tracer: resolvedTracer, logger: resolvedLogger }; } const dsn = process.env.WEB_NEXT_SENTRY_DSN; const result = dsn ? bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" }) : bindNoopInstrumentation(sharedContainer); resolvedTracer = result.tracer; resolvedLogger = result.logger; return result; } /** * Production path: swap each feature's mock repository binding for the real * Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per * feature once Phase E feature wiring lands. Until Phase E, this still calls * the existing `bindProductionX(config)` signature; the unused `tracer`/`logger` * are forwarded by Phase E commits as those binders are updated. */ export async function bindAllProduction(): Promise { if (bound) return; bound = true; resolveInstrumentation(); // Rule 0 const resolvedConfig = await config; bindProductionAuth(resolvedConfig); bindProductionBlog(resolvedConfig); bindProductionMarketingPages(resolvedConfig); bindProductionNavigation(resolvedConfig); bindProductionMedia(resolvedConfig); } /** * Dev-seed path: keep each feature's MockXRepository in place but populate it * with realistic seed data so the running app shows non-empty UI without * Payload booted. Mutually exclusive with `bindAllProduction()`. */ export async function bindAllDevSeed(): Promise { if (bound) return; bound = true; resolveInstrumentation(); // Rule 0 await bindDevSeedAuth(); await bindDevSeedBlog(); await bindDevSeedMarketingPages(); await bindDevSeedNavigation(); await bindDevSeedMedia(); } /** * Boot dispatcher: pick the binder based on the environment. * * Resolution order (first match wins): * * Rule 0 (always): instrumentation (Noop vs Sentry) from WEB_NEXT_SENTRY_DSN * presence — runs inside both bindAllProduction and * bindAllDevSeed via resolveInstrumentation(). * Rule 1: USE_DEV_SEED === "true" → dev seed (explicit override) * Rule 2: NODE_ENV === "production" → real Payload via bindAllProduction * Rule 3: otherwise → dev seed (developer-friendly default) */ export async function bindAll(): Promise { if (process.env.USE_DEV_SEED === "true") { await bindAllDevSeed(); return; } if (process.env.NODE_ENV === "production") { await bindAllProduction(); return; } await bindAllDevSeed(); } /** Test-only resets — not exported via package. Used by bind-production.test.ts. */ export function __resetBindStateForTests(): void { bound = false; resolvedTracer = null; resolvedLogger = null; } /** Test-only accessor for resolved instrumentation. */ export function __getInstrumentationForTests(): { tracer: ITracer | null; logger: ILogger | null; } { return { tracer: resolvedTracer, logger: resolvedLogger }; } ``` - [ ] **Step 2: Run existing bindAllProduction tests to ensure no regression** Run: `pnpm --filter web-next test bind-production` Expected: PASS — existing tests still pass (Rule 0 is additive). - [ ] **Step 3: Commit** ```bash git add apps/web-next/src/server/bind-production.ts git commit -m "feat(app): bindAll() Rule 0 — DSN-driven instrumentation (orthogonal to repo mode)" ``` --- ### Task 14: Tests for `bindAll()` orthogonality (R47) **Files:** - Modify: `apps/web-next/src/server/bind-production.test.ts` - [ ] **Step 1: Add new test block to existing file** Append the following describe block to `apps/web-next/src/server/bind-production.test.ts` (after the existing two describes): ```ts // At the top of the file, extend the existing vi.mock list: vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, bindSentryInstrumentation: vi.fn(actual.bindSentryInstrumentation), bindNoopInstrumentation: vi.fn(actual.bindNoopInstrumentation), }; }); // New describe block: describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); vi.unstubAllEnvs(); }); afterEach(() => { vi.unstubAllEnvs(); }); it("DSN absent → bindNoopInstrumentation regardless of NODE_ENV (R48)", async () => { vi.stubEnv("WEB_NEXT_SENTRY_DSN", ""); vi.stubEnv("NODE_ENV", "production"); const { bindAll } = await import("./bind-production"); const { bindNoopInstrumentation, bindSentryInstrumentation } = await import( "@repo/core-shared/instrumentation" ); await bindAll(); expect(bindNoopInstrumentation).toHaveBeenCalledOnce(); expect(bindSentryInstrumentation).not.toHaveBeenCalled(); }); it("DSN set → bindSentryInstrumentation regardless of NODE_ENV", async () => { vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1"); vi.stubEnv("NODE_ENV", "development"); const { bindAll } = await import("./bind-production"); const { bindNoopInstrumentation, bindSentryInstrumentation } = await import( "@repo/core-shared/instrumentation" ); await bindAll(); expect(bindSentryInstrumentation).toHaveBeenCalledOnce(); expect(bindNoopInstrumentation).not.toHaveBeenCalled(); }); it("Sentry instrumentation works alongside dev seed (USE_DEV_SEED=true)", async () => { vi.stubEnv("USE_DEV_SEED", "true"); vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1"); const { bindAll } = await import("./bind-production"); const { bindSentryInstrumentation } = await import("@repo/core-shared/instrumentation"); const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed"); await bindAll(); expect(bindSentryInstrumentation).toHaveBeenCalledOnce(); expect(bindDevSeedBlog).toHaveBeenCalledOnce(); }); it("Noop instrumentation works alongside production binding (DSN unset, NODE_ENV=production)", async () => { vi.stubEnv("WEB_NEXT_SENTRY_DSN", ""); vi.stubEnv("NODE_ENV", "production"); const { bindAll } = await import("./bind-production"); const { bindNoopInstrumentation } = await import("@repo/core-shared/instrumentation"); const { bindProductionBlog } = await import("@repo/blog/di/bind-production"); await bindAll(); expect(bindNoopInstrumentation).toHaveBeenCalledOnce(); expect(bindProductionBlog).toHaveBeenCalledOnce(); }); }); ``` - [ ] **Step 2: Run the new tests** Run: `pnpm --filter web-next test bind-production` Expected: PASS — original tests + 4 new orthogonality tests. - [ ] **Step 3: Commit** ```bash git add apps/web-next/src/server/bind-production.test.ts git commit -m "test(app): R47 — bindAll instrumentation orthogonality matrix" ``` --- ## Phase D — Test infrastructure (core-testing) > **Implementation note about R49:** The spec's R49 says "vitest setup MUST bind Noop by default." In practice, the codebase has no shared test container — repositories construct themselves directly with `new MockXRepository()`. Therefore "default Noop" is enforced by *default constructor parameters* on every repo (Phase E task work). What `core-testing` provides is a `RecordingTracer` and `RecordingLogger` for tests that want to assert capture/span calls, plus a setup-side guard that fails if Sentry's real SDK accidentally initializes during a test process. ### Task 15: RecordingTracer **Files:** - Create: `packages/core-testing/src/instrumentation/recording-tracer.ts` - Create: `packages/core-testing/src/instrumentation/recording-tracer.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core-testing/src/instrumentation/recording-tracer.test.ts import { describe, it, expect } from "vitest"; import { RecordingTracer } from "@/instrumentation/recording-tracer"; describe("RecordingTracer", () => { it("records every startSpan call with name, op, attributes, status, durationMs", async () => { const tracer = new RecordingTracer(); await tracer.startSpan( { name: "blog.getArticles", op: "use-case", attributes: { limit: 10 } }, async (span) => { span.setAttribute("count", 3); span.setStatus("ok"); return undefined; }, ); expect(tracer.spans).toHaveLength(1); const s = tracer.spans[0]!; expect(s.name).toBe("blog.getArticles"); expect(s.op).toBe("use-case"); expect(s.attributes).toMatchObject({ limit: 10, count: 3 }); expect(s.status).toBe("ok"); expect(typeof s.durationMs).toBe("number"); expect(s.durationMs).toBeGreaterThanOrEqual(0); }); it("records error status when fn throws", async () => { const tracer = new RecordingTracer(); await expect( tracer.startSpan({ name: "x" }, async () => { throw new Error("boom"); }), ).rejects.toThrow("boom"); expect(tracer.spans).toHaveLength(1); expect(tracer.spans[0]!.status).toBe("error"); expect(tracer.spans[0]!.statusMessage).toBe("boom"); }); it("records error status when set explicitly via span.setStatus", async () => { const tracer = new RecordingTracer(); await tracer.startSpan({ name: "x" }, async (span) => { span.setStatus("error", "validation failed"); return undefined; }); expect(tracer.spans[0]!.status).toBe("error"); expect(tracer.spans[0]!.statusMessage).toBe("validation failed"); }); it("reset() clears recorded spans", async () => { const tracer = new RecordingTracer(); await tracer.startSpan({ name: "x" }, async () => undefined); expect(tracer.spans).toHaveLength(1); tracer.reset(); expect(tracer.spans).toHaveLength(0); }); it("findSpan returns first matching span by name", async () => { const tracer = new RecordingTracer(); await tracer.startSpan({ name: "a" }, async () => undefined); await tracer.startSpan({ name: "b" }, async () => undefined); expect(tracer.findSpan("b")).toBeDefined(); expect(tracer.findSpan("missing")).toBeUndefined(); }); it("nested spans are recorded in order (children appear after parent end)", async () => { const tracer = new RecordingTracer(); await tracer.startSpan({ name: "parent" }, async () => { await tracer.startSpan({ name: "child" }, async () => undefined); }); expect(tracer.spans.map((s) => s.name)).toEqual(["child", "parent"]); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm --filter @repo/core-testing test recording-tracer` Expected: FAIL — `RecordingTracer` not found. - [ ] **Step 3: Implement RecordingTracer** ```ts // packages/core-testing/src/instrumentation/recording-tracer.ts import type { ITracer, ISpan, SpanOpts, AttributeValue, } from "@repo/core-shared/instrumentation"; export type RecordedSpan = { name: string; op?: string; attributes: Record; status: "ok" | "error"; statusMessage?: string; durationMs: number; }; export class RecordingTracer implements ITracer { spans: RecordedSpan[] = []; async startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { const start = performance.now(); const recorded: RecordedSpan = { name: opts.name, op: opts.op, attributes: { ...(opts.attributes ?? {}) }, status: "ok", durationMs: 0, }; const span: ISpan = { setAttribute(key, value) { recorded.attributes[key] = value; }, setStatus(status, message) { recorded.status = status; recorded.statusMessage = message; }, }; try { const result = await fn(span); recorded.durationMs = performance.now() - start; this.spans.push(recorded); return result; } catch (err) { recorded.status = "error"; recorded.statusMessage = err instanceof Error ? err.message : String(err); recorded.durationMs = performance.now() - start; this.spans.push(recorded); throw err; } } reset(): void { this.spans = []; } findSpan(name: string): RecordedSpan | undefined { return this.spans.find((s) => s.name === name); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `pnpm --filter @repo/core-testing test recording-tracer` Expected: PASS — 6 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-testing/src/instrumentation/recording-tracer.ts \ packages/core-testing/src/instrumentation/recording-tracer.test.ts git commit -m "feat(core-testing): RecordingTracer for span assertions" ``` --- ### Task 16: RecordingLogger + barrel **Files:** - Create: `packages/core-testing/src/instrumentation/recording-logger.ts` - Create: `packages/core-testing/src/instrumentation/recording-logger.test.ts` - Create: `packages/core-testing/src/instrumentation/index.ts` - Modify: `packages/core-testing/src/index.ts` (re-export) - Modify: `packages/core-testing/package.json` (add `./instrumentation` subpath) - [ ] **Step 1: Write the failing test** ```ts // packages/core-testing/src/instrumentation/recording-logger.test.ts import { describe, it, expect } from "vitest"; import { RecordingLogger } from "@/instrumentation/recording-logger"; describe("RecordingLogger", () => { it("records captureException calls (err + ctx)", () => { const logger = new RecordingLogger(); const err = new Error("x"); logger.captureException(err, { tags: { feature: "blog" } }); expect(logger.captures).toHaveLength(1); expect(logger.captures[0]).toMatchObject({ kind: "exception", err, ctx: { tags: { feature: "blog" } }, }); }); it("records captureMessage calls", () => { const logger = new RecordingLogger(); logger.captureMessage("hello", "warning", { extras: { foo: 1 } }); expect(logger.captures[0]).toMatchObject({ kind: "message", message: "hello", level: "warning", }); }); it("records breadcrumbs", () => { const logger = new RecordingLogger(); logger.addBreadcrumb({ category: "test", message: "x", level: "info" }); expect(logger.breadcrumbs).toHaveLength(1); expect(logger.breadcrumbs[0]!.category).toBe("test"); }); it("records setUser calls", () => { const logger = new RecordingLogger(); logger.setUser({ id: "u1" }); logger.setUser(null); expect(logger.users).toEqual([{ id: "u1" }, null]); }); it("reset() clears all recordings", () => { const logger = new RecordingLogger(); logger.captureException(new Error("x")); logger.addBreadcrumb({ category: "c", message: "m" }); logger.setUser({ id: "u" }); logger.reset(); expect(logger.captures).toHaveLength(0); expect(logger.breadcrumbs).toHaveLength(0); expect(logger.users).toHaveLength(0); }); it("findCapture returns first capture matching predicate", () => { const logger = new RecordingLogger(); logger.captureException(new Error("first")); logger.captureException(new Error("second")); const found = logger.findCapture( (c) => c.kind === "exception" && (c.err as Error).message === "second", ); expect(found).toBeDefined(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm --filter @repo/core-testing test recording-logger` Expected: FAIL — `RecordingLogger` not found. - [ ] **Step 3: Implement RecordingLogger** ```ts // packages/core-testing/src/instrumentation/recording-logger.ts import type { ILogger, Breadcrumb, CaptureContext, } from "@repo/core-shared/instrumentation"; export type RecordedCapture = | { kind: "exception"; err: unknown; ctx?: CaptureContext } | { kind: "message"; message: string; level?: "info" | "warning" | "error"; ctx?: CaptureContext }; export class RecordingLogger implements ILogger { captures: RecordedCapture[] = []; breadcrumbs: Breadcrumb[] = []; users: Array<{ id: string } | null> = []; captureException(err: unknown, ctx?: CaptureContext): void { this.captures.push({ kind: "exception", err, ctx }); } captureMessage( message: string, level?: "info" | "warning" | "error", ctx?: CaptureContext, ): void { this.captures.push({ kind: "message", message, level, ctx }); } addBreadcrumb(b: Breadcrumb): void { this.breadcrumbs.push(b); } setUser(user: { id: string } | null): void { this.users.push(user); } reset(): void { this.captures = []; this.breadcrumbs = []; this.users = []; } findCapture( predicate: (c: RecordedCapture) => boolean, ): RecordedCapture | undefined { return this.captures.find(predicate); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `pnpm --filter @repo/core-testing test recording-logger` Expected: PASS — 6 tests. - [ ] **Step 5: Write the barrel** ```ts // packages/core-testing/src/instrumentation/index.ts export { RecordingTracer, type RecordedSpan } from "./recording-tracer"; export { RecordingLogger, type RecordedCapture } from "./recording-logger"; ``` - [ ] **Step 6: Re-export from package root** Append to `packages/core-testing/src/index.ts`: ```ts export * from "./instrumentation/index.js"; ``` - [ ] **Step 7: Add subpath export in package.json** Update `packages/core-testing/package.json` `exports` field — add the entry while preserving existing entries: ```json { "exports": { ".": "./src/index.ts", "./factory": "./src/factory/index.ts", "./contract": "./src/contract/index.ts", "./instrumentation": "./src/instrumentation/index.ts", "./react": "./src/react/index.ts", "./payload": "./src/payload/index.ts", "./payload/stub-config": "./src/payload/stub-config.ts", "./setup/jsdom": "./src/setup/jsdom.ts", "./setup/node": "./src/setup/node.ts" } } ``` - [ ] **Step 8: Verify build + import** Run: `pnpm --filter @repo/core-testing typecheck` Expected: passes. - [ ] **Step 9: Commit** ```bash git add packages/core-testing/src/instrumentation/ \ packages/core-testing/src/index.ts \ packages/core-testing/package.json git commit -m "feat(core-testing): RecordingLogger + ./instrumentation subpath" ``` --- ### Task 17: vitest setup guard — fail if real Sentry initializes **Files:** - Create: `packages/core-testing/src/setup/no-sentry.ts` - Modify: `packages/core-testing/src/setup/jsdom.ts` (import the guard) - Modify: `packages/core-testing/src/setup/node.ts` (import the guard) - Modify: `packages/core-testing/package.json` (export the new file if needed) > **Why:** R49 forbids real Sentry SDK initialization in test processes. This guard mocks `@sentry/nextjs` globally so any code that imports it gets a no-op surface — including code that wasn't written with testing in mind. Tests that use SentryTracer/SentryLogger directly already mock the module per-file (Tasks 7, 8); this guard is a safety net for indirect imports. - [ ] **Step 1: Write the guard** ```ts // packages/core-testing/src/setup/no-sentry.ts import { vi } from "vitest"; /** * R49 — guard against real Sentry SDK initialization in test processes. * * Mocks @sentry/nextjs at the module level so any code that imports it * receives a no-op surface. Tests that need to assert Sentry behavior * still use vi.mock locally with their own implementation; this guard * just ensures *unintentional* imports don't cause real network/init. */ vi.mock("@sentry/nextjs", () => ({ init: vi.fn(), startSpan: vi.fn((_opts: unknown, fn: any) => fn({ setAttribute: vi.fn(), setStatus: vi.fn() }), ), captureException: vi.fn(), captureMessage: vi.fn(), addBreadcrumb: vi.fn(), setUser: vi.fn(), setContext: vi.fn(), setTag: vi.fn(), setExtra: vi.fn(), withScope: vi.fn((fn: any) => fn({ setTag: vi.fn(), setExtra: vi.fn() })), replayIntegration: vi.fn(() => ({ name: "Replay" })), getActiveSpan: vi.fn(() => undefined), getCurrentHub: vi.fn(() => ({ getClient: () => undefined })), })); ``` - [ ] **Step 2: Import from existing setup files** Edit `packages/core-testing/src/setup/jsdom.ts` — add at the top: ```ts import "./no-sentry"; ``` Edit `packages/core-testing/src/setup/node.ts` — add at the top: ```ts import "./no-sentry"; ``` - [ ] **Step 3: Verify no test regressions** Run: `pnpm test` (full monorepo) Expected: no new failures. Existing tests pass. - [ ] **Step 4: Add a positive test for the guard** Create `packages/core-testing/src/setup/no-sentry.test.ts`: ```ts import { describe, it, expect } from "vitest"; import * as Sentry from "@sentry/nextjs"; describe("setup/no-sentry guard (R49)", () => { it("Sentry.init is a vi.fn (mocked, not real)", () => { expect(vi.isMockFunction(Sentry.init)).toBe(true); }); it("Sentry.captureException is a vi.fn", () => { expect(vi.isMockFunction(Sentry.captureException)).toBe(true); }); it("calling Sentry.init does not throw or initialize", () => { expect(() => Sentry.init({ dsn: "https://x@y/1" } as any)).not.toThrow(); }); }); ``` Run: `pnpm --filter @repo/core-testing test no-sentry` Expected: PASS — 3 tests. - [ ] **Step 5: Commit** ```bash git add packages/core-testing/src/setup/no-sentry.ts \ packages/core-testing/src/setup/no-sentry.test.ts \ packages/core-testing/src/setup/jsdom.ts \ packages/core-testing/src/setup/node.ts git commit -m "feat(core-testing): R49 guard — block real Sentry SDK init in test processes" ``` --- ## Phase E — Per-feature wiring > **Pattern reference (applies to every feature task in this phase):** > > 1. **Real repository class** — constructor signature changes from `(config)` to `(config, tracer = new NoopTracer(), logger = new NoopLogger())`. Each public async method's body is wrapped: > > ```ts > async findX(args): Promise { > return this.tracer.startSpan( > { name: ".", op: "repository", attributes: { ...documentedAttrs } }, > async (span) => { > try { > const result = await /* existing payload op */; > span.setAttribute("count", /* if applicable */); > return /* mapped result */; > } catch (err) { > this.logger.captureException(err, { > tags: { feature: "", repo: "", method: "" }, > }); > span.setStatus("error", err instanceof Error ? err.message : String(err)); > throw err; > } > }, > ); > } > ``` > > 2. **Mock repository class** — constructor signature change identical to real (with NoopTracer/NoopLogger defaults). Body wrapping identical structure (calls `tracer.startSpan` with the same name/op/attrs), but the body just runs the mock's existing logic — no `try/catch logger.captureException` because mocks don't originate infra errors. Pattern: > > ```ts > async findX(args): Promise { > return this.tracer.startSpan( > { name: ".", op: "repository", attributes: { ... } }, > async () => /* existing mock logic */, > ); > } > ``` > > 3. **`bind-production.ts`** — signature: `bindProductionX(config, tracer, logger)`. Wrap factory results with `withSpan` at bind time. Bind TRACER/LOGGER to feature container as `.toConstantValue(...)`. For each use case: construct real factory, wrap with `withSpan(tracer, { name: ".", op: "use-case" }, factory(...deps))`, bind to symbol. For each controller: same pattern with `op: "controller"`. > > 4. **`bind-dev-seed.ts`** — signature: `bindDevSeedX(tracer, logger)`. Same wrapping but mock repo gets the same tracer/logger (mocks pass `tracer.startSpan` through but emit recorded spans for tests/dev breakdowns). > > 5. **Tests** — direct injection of `RecordingTracer` / `RecordingLogger` into the factory. No DI container manipulation. Assert span shape and capture calls. > > 6. **`apps/web-next/src/server/bind-production.ts`** — once the feature's binder signature changes, update its caller in this dispatcher to pass `tracer` + `logger`. Keep `bindAllProduction` and `bindAllDevSeed` consistent. ### Task 18: Blog feature wiring (pilot) **Files:** - Modify: `packages/blog/src/infrastructure/repositories/articles.repository.ts` - Modify: `packages/blog/src/infrastructure/repositories/articles.repository.mock.ts` - Modify: `packages/blog/src/di/bind-production.ts` - Modify: `packages/blog/src/di/bind-dev-seed.ts` - Modify: `apps/web-next/src/server/bind-production.ts` (update calls to bindProductionBlog / bindDevSeedBlog) - Modify (existing): blog repo + use-case + controller test files (direct-injection pattern updated for tracer/logger) **Repository methods to wrap (5 each):** | Method | name | attributes | |---|---|---| | `getArticle` | `articles.getArticle` | `{ id }` | | `getArticleBySlug` | `articles.getArticleBySlug` | `{ slug }` | | `getArticles` | `articles.getArticles` | `{ status, authorId, limit, offset }` (only present keys) | | `createArticle` | `articles.createArticle` | `{ slug }` | | `updateArticle` | `articles.updateArticle` | `{ id }` | **Use cases to wrap (3 — name, op="use-case"):** - `blog.getArticles` - `blog.getArticleBySlug` - `blog.createArticle` **Controllers to wrap (3 — name, op="controller"):** same names. - [ ] **Step 1: Update articles.repository.ts (real)** ```ts // packages/blog/src/infrastructure/repositories/articles.repository.ts import "reflect-metadata"; import { injectable } from "inversify"; import { getPayload } from "payload"; import type { SanitizedConfig } from "payload"; import { NoopTracer, NoopLogger, type ITracer, type ILogger, } from "@repo/core-shared/instrumentation"; import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface"; import type { Article } from "../../entities/models/article"; type PayloadArticleDoc = { id: string | number; title?: string | null; slug?: string | null; content?: unknown; status?: string | null; author?: string | number | { id: string | number } | null; createdAt?: string | null; updatedAt?: string | null; }; function mapDoc(doc: PayloadArticleDoc): Article { const authorId = typeof doc.author === "object" && doc.author !== null ? String(doc.author.id) : doc.author != null ? String(doc.author) : ""; return { id: String(doc.id), title: doc.title ?? "", slug: doc.slug ?? "", content: doc.content ?? null, status: doc.status === "published" ? "published" : "draft", authorId, createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0), updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0), }; } const FEATURE = "blog" as const; const REPO = "articles" as const; @injectable() export class ArticlesRepository implements IArticlesRepository { private config: SanitizedConfig; private tracer: ITracer; private logger: ILogger; constructor( config: SanitizedConfig, tracer: ITracer = new NoopTracer(), logger: ILogger = new NoopLogger(), ) { this.config = config; this.tracer = tracer; this.logger = logger; } async getArticle(id: string): Promise
{ return this.tracer.startSpan( { name: "articles.getArticle", op: "repository", attributes: { id } }, async (span) => { try { const payload = await getPayload({ config: this.config }); const doc = await payload.findByID({ collection: "articles", id, overrideAccess: true, }); span.setAttribute("found", true); return mapDoc(doc as PayloadArticleDoc); } catch (err) { // Payload throws on not-found; treat as undefined per existing semantics if (err && typeof err === "object" && "status" in err && (err as any).status === 404) { span.setAttribute("found", false); return undefined; } this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "getArticle" }, }); span.setStatus("error", err instanceof Error ? err.message : String(err)); throw err; } }, ); } async getArticleBySlug(slug: string): Promise
{ return this.tracer.startSpan( { name: "articles.getArticleBySlug", op: "repository", attributes: { slug } }, async (span) => { try { const payload = await getPayload({ config: this.config }); const result = await payload.find({ collection: "articles", where: { slug: { equals: slug } }, limit: 1, overrideAccess: true, }); const doc = result.docs[0] as PayloadArticleDoc | undefined; span.setAttribute("found", Boolean(doc)); return doc ? mapDoc(doc) : undefined; } catch (err) { this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "getArticleBySlug" }, }); span.setStatus("error", err instanceof Error ? err.message : String(err)); throw err; } }, ); } async getArticles(options?: { status?: string; authorId?: string; limit?: number; offset?: number; }): Promise { return this.tracer.startSpan( { name: "articles.getArticles", op: "repository", attributes: { status: options?.status ?? null, authorId: options?.authorId ?? null, limit: options?.limit ?? null, offset: options?.offset ?? null, }, }, async (span) => { try { const payload = await getPayload({ config: this.config }); const where: Record = {}; if (options?.status) where.status = { equals: options.status }; if (options?.authorId) where.author = { equals: options.authorId }; const result = await payload.find({ collection: "articles", where: where as never, limit: options?.limit ?? 50, page: options?.offset ? Math.floor(options.offset / (options.limit ?? 50)) + 1 : 1, overrideAccess: true, }); span.setAttribute("count", result.docs.length); return result.docs.map((d) => mapDoc(d as PayloadArticleDoc)); } catch (err) { this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "getArticles" }, }); span.setStatus("error", err instanceof Error ? err.message : String(err)); throw err; } }, ); } async createArticle(input: Article): Promise
{ return this.tracer.startSpan( { name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } }, async (span) => { try { const payload = await getPayload({ config: this.config }); const created = await payload.create({ collection: "articles", data: { title: input.title, slug: input.slug, content: input.content, status: input.status, author: input.authorId, } as never, overrideAccess: true, }); span.setAttribute("id", String((created as PayloadArticleDoc).id)); return mapDoc(created as PayloadArticleDoc); } catch (err) { this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "createArticle" }, }); span.setStatus("error", err instanceof Error ? err.message : String(err)); throw err; } }, ); } async updateArticle( id: string, input: Partial
, ): Promise
{ return this.tracer.startSpan( { name: "articles.updateArticle", op: "repository", attributes: { id } }, async (span) => { try { const payload = await getPayload({ config: this.config }); const updated = await payload.update({ collection: "articles", id, data: { ...(input.title !== undefined && { title: input.title }), ...(input.slug !== undefined && { slug: input.slug }), ...(input.content !== undefined && { content: input.content }), ...(input.status !== undefined && { status: input.status }), ...(input.authorId !== undefined && { author: input.authorId }), } as never, overrideAccess: true, }); span.setAttribute("found", true); return mapDoc(updated as PayloadArticleDoc); } catch (err) { if (err && typeof err === "object" && "status" in err && (err as any).status === 404) { span.setAttribute("found", false); return undefined; } this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "updateArticle" }, }); span.setStatus("error", err instanceof Error ? err.message : String(err)); throw err; } }, ); } } ``` - [ ] **Step 2: Update articles.repository.mock.ts** ```ts // packages/blog/src/infrastructure/repositories/articles.repository.mock.ts import "reflect-metadata"; import { injectable } from "inversify"; import { NoopTracer, NoopLogger, type ITracer, type ILogger, } from "@repo/core-shared/instrumentation"; import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface"; import type { Article } from "../../entities/models/article"; @injectable() export class MockArticlesRepository implements IArticlesRepository { private _articles: Article[] = []; private tracer: ITracer; private logger: ILogger; constructor( tracer: ITracer = new NoopTracer(), logger: ILogger = new NoopLogger(), ) { this.tracer = tracer; this.logger = logger; void this.logger; // currently unused; reserved for future mock-thrown captures } async getArticle(id: string): Promise
{ return this.tracer.startSpan( { name: "articles.getArticle", op: "repository", attributes: { id } }, async (span) => { const found = this._articles.find((a) => a.id === id); span.setAttribute("found", Boolean(found)); return found; }, ); } async getArticleBySlug(slug: string): Promise
{ return this.tracer.startSpan( { name: "articles.getArticleBySlug", op: "repository", attributes: { slug } }, async (span) => { const found = this._articles.find((a) => a.slug === slug); span.setAttribute("found", Boolean(found)); return found; }, ); } async getArticles(options?: { status?: string; authorId?: string; limit?: number; offset?: number; }): Promise { return this.tracer.startSpan( { name: "articles.getArticles", op: "repository", attributes: { status: options?.status ?? null, authorId: options?.authorId ?? null, limit: options?.limit ?? null, offset: options?.offset ?? null, }, }, async (span) => { let result = [...this._articles]; if (options?.status) { result = result.filter((a) => a.status === options.status); } if (options?.authorId) { result = result.filter((a) => a.authorId === options.authorId); } const offset = options?.offset ?? 0; const limit = options?.limit ?? 50; const sliced = result.slice(offset, offset + limit); span.setAttribute("count", sliced.length); return sliced; }, ); } async createArticle(input: Article): Promise
{ return this.tracer.startSpan( { name: "articles.createArticle", op: "repository", attributes: { slug: input.slug } }, async (span) => { this._articles.push(input); span.setAttribute("id", input.id); return input; }, ); } async updateArticle( id: string, input: Partial
, ): Promise
{ return this.tracer.startSpan( { name: "articles.updateArticle", op: "repository", attributes: { id } }, async (span) => { const idx = this._articles.findIndex((a) => a.id === id); if (idx === -1) { span.setAttribute("found", false); return undefined; } const merged = { ...this._articles[idx]!, ...input, id } as Article; this._articles[idx] = merged; span.setAttribute("found", true); return merged; }, ); } } ``` - [ ] **Step 3: Update bind-production.ts** ```ts // packages/blog/src/di/bind-production.ts import type { SanitizedConfig } from "payload"; import { withSpan, INSTRUMENTATION_SYMBOLS, type ITracer, type ILogger, } from "@repo/core-shared/instrumentation"; import { blogContainer } from "./container"; import { BLOG_SYMBOLS } from "./symbols"; import { ArticlesRepository } from "../infrastructure/repositories/articles.repository"; // Import every use-case + controller factory: import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case"; import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case"; import { createArticleUseCase } from "../application/use-cases/create-article.use-case"; import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller"; import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller"; import { createArticleController } from "../interface-adapters/controllers/create-article.controller"; export function bindProductionBlog( config: SanitizedConfig, tracer: ITracer, logger: ILogger, ): void { // Bind shared instrumentation into feature container (for any internal resolvers) if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); } if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); } blogContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); blogContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); // Real repository if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } const repo = new ArticlesRepository(config, tracer, logger); blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo); // Use cases — wrapped with span at bind time (R41) const wrappedGetArticles = withSpan( tracer, { name: "blog.getArticles", op: "use-case" }, getArticlesUseCase(repo), ); const wrappedGetArticleBySlug = withSpan( tracer, { name: "blog.getArticleBySlug", op: "use-case" }, getArticleBySlugUseCase(repo), ); const wrappedCreateArticle = withSpan( tracer, { name: "blog.createArticle", op: "use-case" }, createArticleUseCase(repo), ); if (blogContainer.isBound(BLOG_SYMBOLS.GetArticlesUseCase)) { blogContainer.unbind(BLOG_SYMBOLS.GetArticlesUseCase); } if (blogContainer.isBound(BLOG_SYMBOLS.GetArticleBySlugUseCase)) { blogContainer.unbind(BLOG_SYMBOLS.GetArticleBySlugUseCase); } if (blogContainer.isBound(BLOG_SYMBOLS.CreateArticleUseCase)) { blogContainer.unbind(BLOG_SYMBOLS.CreateArticleUseCase); } blogContainer.bind(BLOG_SYMBOLS.GetArticlesUseCase).toConstantValue(wrappedGetArticles); blogContainer .bind(BLOG_SYMBOLS.GetArticleBySlugUseCase) .toConstantValue(wrappedGetArticleBySlug); blogContainer.bind(BLOG_SYMBOLS.CreateArticleUseCase).toConstantValue(wrappedCreateArticle); // Controllers — wrapped with span at bind time const wrappedGetArticlesCtrl = withSpan( tracer, { name: "blog.getArticles", op: "controller" }, getArticlesController(wrappedGetArticles), ); const wrappedGetArticleBySlugCtrl = withSpan( tracer, { name: "blog.getArticleBySlug", op: "controller" }, getArticleBySlugController(wrappedGetArticleBySlug), ); const wrappedCreateArticleCtrl = withSpan( tracer, { name: "blog.createArticle", op: "controller" }, createArticleController(wrappedCreateArticle), ); if (blogContainer.isBound(BLOG_SYMBOLS.GetArticlesController)) { blogContainer.unbind(BLOG_SYMBOLS.GetArticlesController); } if (blogContainer.isBound(BLOG_SYMBOLS.GetArticleBySlugController)) { blogContainer.unbind(BLOG_SYMBOLS.GetArticleBySlugController); } if (blogContainer.isBound(BLOG_SYMBOLS.CreateArticleController)) { blogContainer.unbind(BLOG_SYMBOLS.CreateArticleController); } blogContainer .bind(BLOG_SYMBOLS.GetArticlesController) .toConstantValue(wrappedGetArticlesCtrl); blogContainer .bind(BLOG_SYMBOLS.GetArticleBySlugController) .toConstantValue(wrappedGetArticleBySlugCtrl); blogContainer .bind(BLOG_SYMBOLS.CreateArticleController) .toConstantValue(wrappedCreateArticleCtrl); } ``` > **Note:** Replace exact use-case / controller symbol names if `BLOG_SYMBOLS` differs (read `packages/blog/src/di/symbols.ts` first). The pattern is identical regardless of names. - [ ] **Step 4: Update bind-dev-seed.ts** ```ts // packages/blog/src/di/bind-dev-seed.ts import { withSpan, INSTRUMENTATION_SYMBOLS, type ITracer, type ILogger, } from "@repo/core-shared/instrumentation"; import { blogContainer } from "./container.js"; import { BLOG_SYMBOLS } from "./symbols.js"; import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock.js"; import { buildDevArticles } from "../__seeds__/dev.js"; import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case.js"; import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case.js"; import { createArticleUseCase } from "../application/use-cases/create-article.use-case.js"; import { getArticlesController } from "../interface-adapters/controllers/get-articles.controller.js"; import { getArticleBySlugController } from "../interface-adapters/controllers/get-article-by-slug.controller.js"; import { createArticleController } from "../interface-adapters/controllers/create-article.controller.js"; import type { IArticlesRepository } from "../application/repositories/articles.repository.interface.js"; export async function bindDevSeedBlog(tracer: ITracer, logger: ILogger): Promise { if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER); } if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) { blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER); } blogContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); blogContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } const repo = new MockArticlesRepository(tracer, logger); for (const article of buildDevArticles()) { await repo.createArticle(article); } blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) .toConstantValue(repo); // Wrap use cases + controllers identically to bind-production const wrappedGetArticles = withSpan( tracer, { name: "blog.getArticles", op: "use-case" }, getArticlesUseCase(repo), ); const wrappedGetArticleBySlug = withSpan( tracer, { name: "blog.getArticleBySlug", op: "use-case" }, getArticleBySlugUseCase(repo), ); const wrappedCreateArticle = withSpan( tracer, { name: "blog.createArticle", op: "use-case" }, createArticleUseCase(repo), ); for (const sym of [ BLOG_SYMBOLS.GetArticlesUseCase, BLOG_SYMBOLS.GetArticleBySlugUseCase, BLOG_SYMBOLS.CreateArticleUseCase, BLOG_SYMBOLS.GetArticlesController, BLOG_SYMBOLS.GetArticleBySlugController, BLOG_SYMBOLS.CreateArticleController, ]) { if (blogContainer.isBound(sym)) blogContainer.unbind(sym); } blogContainer.bind(BLOG_SYMBOLS.GetArticlesUseCase).toConstantValue(wrappedGetArticles); blogContainer .bind(BLOG_SYMBOLS.GetArticleBySlugUseCase) .toConstantValue(wrappedGetArticleBySlug); blogContainer.bind(BLOG_SYMBOLS.CreateArticleUseCase).toConstantValue(wrappedCreateArticle); blogContainer .bind(BLOG_SYMBOLS.GetArticlesController) .toConstantValue( withSpan( tracer, { name: "blog.getArticles", op: "controller" }, getArticlesController(wrappedGetArticles), ), ); blogContainer .bind(BLOG_SYMBOLS.GetArticleBySlugController) .toConstantValue( withSpan( tracer, { name: "blog.getArticleBySlug", op: "controller" }, getArticleBySlugController(wrappedGetArticleBySlug), ), ); blogContainer .bind(BLOG_SYMBOLS.CreateArticleController) .toConstantValue( withSpan( tracer, { name: "blog.createArticle", op: "controller" }, createArticleController(wrappedCreateArticle), ), ); } ``` - [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts to thread tracer + logger to blog** In `bindAllProduction()` and `bindAllDevSeed()`, change the calls to blog binders: ```ts // apps/web-next/src/server/bind-production.ts (excerpt) export async function bindAllProduction(): Promise { if (bound) return; bound = true; const { tracer, logger } = resolveInstrumentation(); // Rule 0 const resolvedConfig = await config; bindProductionAuth(resolvedConfig); // Phase E task 19 will update bindProductionBlog(resolvedConfig, tracer, logger); // ← updated this task bindProductionMarketingPages(resolvedConfig); // Phase E task 20 will update bindProductionNavigation(resolvedConfig); // Phase E task 21 will update bindProductionMedia(resolvedConfig); // Phase E task 22 will update } export async function bindAllDevSeed(): Promise { if (bound) return; bound = true; const { tracer, logger } = resolveInstrumentation(); // Rule 0 await bindDevSeedAuth(); // task 19 await bindDevSeedBlog(tracer, logger); // ← updated this task await bindDevSeedMarketingPages(); // task 20 await bindDevSeedNavigation(); // task 21 await bindDevSeedMedia(); // task 22 } ``` - [ ] **Step 6: Update existing tests to pass tracer/logger or use defaults** Fix any blog test that constructs `new MockArticlesRepository()` — it still works (Noop defaults), no edit needed unless the test asserts on span shape (those land in Task 24's contract suite update). Run existing test suite: Run: `pnpm --filter @repo/blog test` Expected: PASS — existing tests still pass with Noop defaults. Run: `pnpm --filter web-next test` Expected: PASS. - [ ] **Step 7: Add a span-shape test for the real repo (sanity)** Create `packages/blog/src/infrastructure/repositories/articles.repository.span.test.ts`: ```ts import { describe, it, expect } from "vitest"; import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; // Mock repo also wraps in spans (R42); easier to assert without booting Payload. describe("MockArticlesRepository emits spans (R42)", () => { it("getArticles emits one span with op='repository'", async () => { const tracer = new RecordingTracer(); const logger = new RecordingLogger(); const repo = new MockArticlesRepository(tracer, logger); await repo.getArticles({ limit: 10 }); expect(tracer.spans).toHaveLength(1); expect(tracer.spans[0]).toMatchObject({ name: "articles.getArticles", op: "repository", }); expect(tracer.spans[0]!.attributes).toMatchObject({ limit: 10 }); }); it("createArticle emits a span with slug attribute", async () => { const tracer = new RecordingTracer(); const repo = new MockArticlesRepository(tracer); await repo.createArticle({ id: "a1", title: "T", slug: "t", content: null, status: "draft", authorId: "u1", createdAt: new Date(), updatedAt: new Date(), }); expect(tracer.findSpan("articles.createArticle")).toBeDefined(); expect(tracer.findSpan("articles.createArticle")!.attributes.slug).toBe("t"); }); it("getArticle records found=false for missing id", async () => { const tracer = new RecordingTracer(); const repo = new MockArticlesRepository(tracer); await repo.getArticle("missing"); expect(tracer.spans[0]!.attributes.found).toBe(false); }); }); ``` Run: `pnpm --filter @repo/blog test articles.repository.span` Expected: PASS — 3 tests. - [ ] **Step 8: Commit** ```bash git add packages/blog/src/infrastructure/repositories/articles.repository.ts \ packages/blog/src/infrastructure/repositories/articles.repository.mock.ts \ packages/blog/src/infrastructure/repositories/articles.repository.span.test.ts \ packages/blog/src/di/bind-production.ts \ packages/blog/src/di/bind-dev-seed.ts \ apps/web-next/src/server/bind-production.ts git commit -m "feat(blog): wire instrumentation — repo spans + use-case/controller withSpan + logger capture" ``` --- ### Task 19: Auth feature wiring **Files:** - Modify: `packages/auth/src/infrastructure/repositories/users.repository.ts` - Modify: `packages/auth/src/infrastructure/repositories/users.repository.mock.ts` - Modify: `packages/auth/src/di/bind-production.ts` - Modify: `packages/auth/src/di/bind-dev-seed.ts` - Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger to auth binders) **Use cases:** `auth.signIn`, `auth.signUp`, `auth.signOut` (all 3, `op: "use-case"` and `op: "controller"`). **Repository methods:** read the actual file first (`packages/auth/src/infrastructure/repositories/users.repository.ts`) and apply the wrapping pattern from Phase E preamble to every public async method. Span name format: `users.`. Standard attributes: `id`, `email` (note — `email` here is *only* used as a span attribute for lookup; it is NOT sent to Sentry as PII because span attributes are scrubbed by the same R32 scrubber). To be safe, hash or truncate emails in attributes: ```ts attributes: { emailDomain: email.split("@")[1] ?? "(invalid)" } ``` This avoids putting raw email in trace data even though scrubbers would catch it downstream. - [ ] **Step 1: Read existing files** Open each file and note the method signatures + bodies: - `packages/auth/src/infrastructure/repositories/users.repository.ts` - `packages/auth/src/infrastructure/repositories/users.repository.mock.ts` - `packages/auth/src/di/bind-production.ts` - `packages/auth/src/di/bind-dev-seed.ts` - [ ] **Step 2: Apply Phase E preamble pattern to real users.repository.ts** For every public async method, wrap the body in `tracer.startSpan({ name: "users.", op: "repository", attributes: {...} }, ...)`. Add catch block calling `this.logger.captureException(err, { tags: { feature: "auth", repo: "users", method: "" } })`. Constructor signature change: add `tracer: ITracer = new NoopTracer()`, `logger: ILogger = new NoopLogger()` parameters. Apply identical mock wrapping (omitting catch block). (Use the blog real-repo + mock-repo code from Task 18 as the structural template; only the actual Payload calls and the method names change.) - [ ] **Step 3: Update bind-production.ts** ```ts // packages/auth/src/di/bind-production.ts import type { SanitizedConfig } from "payload"; import { withSpan, INSTRUMENTATION_SYMBOLS, type ITracer, type ILogger, } from "@repo/core-shared/instrumentation"; import { authContainer } from "./container"; import { AUTH_SYMBOLS } from "./symbols"; import { UsersRepository } from "../infrastructure/repositories/users.repository"; import { signInUseCase } from "../application/use-cases/sign-in.use-case"; import { signUpUseCase } from "../application/use-cases/sign-up.use-case"; import { signOutUseCase } from "../application/use-cases/sign-out.use-case"; import { signInController } from "../interface-adapters/controllers/sign-in.controller"; import { signUpController } from "../interface-adapters/controllers/sign-up.controller"; import { signOutController } from "../interface-adapters/controllers/sign-out.controller"; export function bindProductionAuth( config: SanitizedConfig, tracer: ITracer, logger: ILogger, ): void { // Bind shared instrumentation for (const sym of [INSTRUMENTATION_SYMBOLS.TRACER, INSTRUMENTATION_SYMBOLS.LOGGER]) { if (authContainer.isBound(sym)) authContainer.unbind(sym); } authContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); authContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); // Repository if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); } const repo = new UsersRepository(config, tracer, logger); authContainer.bind(AUTH_SYMBOLS.IUsersRepository).toConstantValue(repo); // The auth feature also has IAuthenticationService — read existing bind-production to // understand what additional services exist; bind them similarly. // (If auth's bind-production binds an authentication service, follow the same pattern.) // Use cases const wrappedSignIn = withSpan( tracer, { name: "auth.signIn", op: "use-case" }, signInUseCase(repo /*, ...other deps */), ); const wrappedSignUp = withSpan( tracer, { name: "auth.signUp", op: "use-case" }, signUpUseCase(repo /*, ...other deps */), ); const wrappedSignOut = withSpan( tracer, { name: "auth.signOut", op: "use-case" }, signOutUseCase(/* deps */), ); for (const sym of [ AUTH_SYMBOLS.SignInUseCase, AUTH_SYMBOLS.SignUpUseCase, AUTH_SYMBOLS.SignOutUseCase, AUTH_SYMBOLS.SignInController, AUTH_SYMBOLS.SignUpController, AUTH_SYMBOLS.SignOutController, ]) { if (authContainer.isBound(sym)) authContainer.unbind(sym); } authContainer.bind(AUTH_SYMBOLS.SignInUseCase).toConstantValue(wrappedSignIn); authContainer.bind(AUTH_SYMBOLS.SignUpUseCase).toConstantValue(wrappedSignUp); authContainer.bind(AUTH_SYMBOLS.SignOutUseCase).toConstantValue(wrappedSignOut); // Controllers authContainer .bind(AUTH_SYMBOLS.SignInController) .toConstantValue( withSpan( tracer, { name: "auth.signIn", op: "controller" }, signInController(wrappedSignIn), ), ); authContainer .bind(AUTH_SYMBOLS.SignUpController) .toConstantValue( withSpan( tracer, { name: "auth.signUp", op: "controller" }, signUpController(wrappedSignUp), ), ); authContainer .bind(AUTH_SYMBOLS.SignOutController) .toConstantValue( withSpan( tracer, { name: "auth.signOut", op: "controller" }, signOutController(wrappedSignOut), ), ); } ``` > **Note:** The existing `bind-production.ts` may bind additional dependencies (e.g., `IAuthenticationService`). Read the current file first and preserve any bindings beyond the repository — apply the same `tracer`/`logger` constructor extension to those service classes too if they're feature-owned implementations. - [ ] **Step 4: Update bind-dev-seed.ts** Apply Phase E preamble pattern: signature `(tracer, logger)`, mock repo gets tracer/logger, every use case + controller is wrapped via `withSpan`. Use Task 18's `bind-dev-seed.ts` as structural reference. - [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts** ```ts // In bindAllProduction: bindProductionAuth(resolvedConfig, tracer, logger); // In bindAllDevSeed: await bindDevSeedAuth(tracer, logger); ``` - [ ] **Step 6: Span-shape test for the auth mock repo** Create `packages/auth/src/infrastructure/repositories/users.repository.span.test.ts` matching Task 18 Step 7 — assert at least one method emits a `users.` span with `op: "repository"` and the documented attributes. - [ ] **Step 7: Run feature + app tests** Run: `pnpm --filter @repo/auth test && pnpm --filter web-next test` Expected: PASS — existing tests with Noop defaults; new span-shape test passes. - [ ] **Step 8: Commit** ```bash git add packages/auth/src/ apps/web-next/src/server/bind-production.ts git commit -m "feat(auth): wire instrumentation — users repo spans + sign-in/up/out withSpan" ``` --- ### Task 20: Marketing-pages feature wiring **Files:** - Modify: `packages/marketing-pages/src/infrastructure/repositories/site-settings.repository.ts` - Modify: `packages/marketing-pages/src/infrastructure/repositories/site-settings.repository.mock.ts` - Modify: `packages/marketing-pages/src/infrastructure/repositories/pages.repository.ts` - Modify: `packages/marketing-pages/src/infrastructure/repositories/pages.repository.mock.ts` - Modify: `packages/marketing-pages/src/di/bind-production.ts` - Modify: `packages/marketing-pages/src/di/bind-dev-seed.ts` - Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger) **Two repos:** site-settings + pages. **Use cases:** `marketing-pages.getSiteSettings`, `marketing-pages.getPageBySlug`. - [ ] **Step 1: Read existing files** (the 6 listed above + use-case + controller files). - [ ] **Step 2: Apply Phase E preamble pattern to both repositories** (real + mock for each), spans named `site-settings.` and `pages.`. - [ ] **Step 3: Update bind-production.ts** ```ts // packages/marketing-pages/src/di/bind-production.ts (skeleton) import type { SanitizedConfig } from "payload"; import { withSpan, INSTRUMENTATION_SYMBOLS, type ITracer, type ILogger } from "@repo/core-shared/instrumentation"; import { marketingPagesContainer } from "./container"; import { MARKETING_PAGES_SYMBOLS } from "./symbols"; import { SiteSettingsRepository } from "../infrastructure/repositories/site-settings.repository"; import { PagesRepository } from "../infrastructure/repositories/pages.repository"; import { getSiteSettingsUseCase } from "../application/use-cases/get-site-settings.use-case"; import { getPageBySlugUseCase } from "../application/use-cases/get-page-by-slug.use-case"; import { getSiteSettingsController } from "../interface-adapters/controllers/get-site-settings.controller"; import { getPageBySlugController } from "../interface-adapters/controllers/get-page-by-slug.controller"; export function bindProductionMarketingPages( config: SanitizedConfig, tracer: ITracer, logger: ILogger, ): void { for (const sym of [INSTRUMENTATION_SYMBOLS.TRACER, INSTRUMENTATION_SYMBOLS.LOGGER]) { if (marketingPagesContainer.isBound(sym)) marketingPagesContainer.unbind(sym); } marketingPagesContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer); marketingPagesContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger); // Two repos for (const sym of [MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, MARKETING_PAGES_SYMBOLS.IPagesRepository]) { if (marketingPagesContainer.isBound(sym)) marketingPagesContainer.unbind(sym); } const settingsRepo = new SiteSettingsRepository(config, tracer, logger); const pagesRepo = new PagesRepository(config, tracer, logger); marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository).toConstantValue(settingsRepo); marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.IPagesRepository).toConstantValue(pagesRepo); const wrappedGetSettings = withSpan( tracer, { name: "marketing-pages.getSiteSettings", op: "use-case" }, getSiteSettingsUseCase(settingsRepo), ); const wrappedGetPage = withSpan( tracer, { name: "marketing-pages.getPageBySlug", op: "use-case" }, getPageBySlugUseCase(pagesRepo), ); for (const sym of [ MARKETING_PAGES_SYMBOLS.GetSiteSettingsUseCase, MARKETING_PAGES_SYMBOLS.GetPageBySlugUseCase, MARKETING_PAGES_SYMBOLS.GetSiteSettingsController, MARKETING_PAGES_SYMBOLS.GetPageBySlugController, ]) { if (marketingPagesContainer.isBound(sym)) marketingPagesContainer.unbind(sym); } marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.GetSiteSettingsUseCase).toConstantValue(wrappedGetSettings); marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.GetPageBySlugUseCase).toConstantValue(wrappedGetPage); marketingPagesContainer .bind(MARKETING_PAGES_SYMBOLS.GetSiteSettingsController) .toConstantValue( withSpan( tracer, { name: "marketing-pages.getSiteSettings", op: "controller" }, getSiteSettingsController(wrappedGetSettings), ), ); marketingPagesContainer .bind(MARKETING_PAGES_SYMBOLS.GetPageBySlugController) .toConstantValue( withSpan( tracer, { name: "marketing-pages.getPageBySlug", op: "controller" }, getPageBySlugController(wrappedGetPage), ), ); } ``` - [ ] **Step 4: Update bind-dev-seed.ts** — same pattern as Task 18 Step 4 with marketing-pages's use cases + controllers. - [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts** ```ts // In bindAllProduction: bindProductionMarketingPages(resolvedConfig, tracer, logger); // In bindAllDevSeed: await bindDevSeedMarketingPages(tracer, logger); ``` - [ ] **Step 6: Span-shape tests** — one per repo (site-settings + pages). - [ ] **Step 7: Run tests** Run: `pnpm --filter @repo/marketing-pages test && pnpm --filter web-next test` Expected: PASS. - [ ] **Step 8: Commit** ```bash git add packages/marketing-pages/src/ apps/web-next/src/server/bind-production.ts git commit -m "feat(marketing-pages): wire instrumentation — site-settings + pages spans + use-case/controller withSpan" ``` --- ### Task 21: Navigation feature wiring **Files:** - Modify: `packages/navigation/src/infrastructure/repositories/header.repository.ts` - Modify: `packages/navigation/src/infrastructure/repositories/header.repository.mock.ts` - Modify: `packages/navigation/src/di/bind-production.ts` - Modify: `packages/navigation/src/di/bind-dev-seed.ts` - Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger) **Single repo:** header. **Single use case:** `navigation.getHeader`. - [ ] **Step 1: Read existing files.** - [ ] **Step 2: Apply Phase E preamble pattern to header.repository.ts (real + mock)** — spans `header.`. - [ ] **Step 3: Update bind-production.ts** — same pattern as Task 18 Step 3, scoped to one use case + controller (`getHeader`). - [ ] **Step 4: Update bind-dev-seed.ts** — same pattern as Task 18 Step 4. - [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts** ```ts bindProductionNavigation(resolvedConfig, tracer, logger); await bindDevSeedNavigation(tracer, logger); ``` - [ ] **Step 6: Span-shape test** for the header mock repo. - [ ] **Step 7: Run tests** Run: `pnpm --filter @repo/navigation test && pnpm --filter web-next test` Expected: PASS. - [ ] **Step 8: Commit** ```bash git add packages/navigation/src/ apps/web-next/src/server/bind-production.ts git commit -m "feat(navigation): wire instrumentation — header repo spans + getHeader withSpan" ``` --- ### Task 22: Media feature wiring **Files:** - Modify: `packages/media/src/infrastructure/repositories/media.repository.ts` - Modify: `packages/media/src/infrastructure/repositories/media.repository.mock.ts` - Modify: `packages/media/src/di/bind-production.ts` - Modify: `packages/media/src/di/bind-dev-seed.ts` - Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger) **Single repo:** media. **Use cases:** `media.getMedia`, `media.listMedia`, `media.deleteMedia`. - [ ] **Step 1: Read existing files.** - [ ] **Step 2: Apply Phase E preamble pattern to media.repository.ts (real + mock)** — spans `media.`. Note: `deleteMedia` is a side-effect operation; record `id` + `deleted: true|false` attributes. - [ ] **Step 3: Update bind-production.ts** — same pattern as Task 18 Step 3 with media's 3 use cases + controllers. - [ ] **Step 4: Update bind-dev-seed.ts** — same pattern. - [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts** ```ts bindProductionMedia(resolvedConfig, tracer, logger); await bindDevSeedMedia(tracer, logger); ``` - [ ] **Step 6: Span-shape test** for media mock repo. - [ ] **Step 7: Run tests** Run: `pnpm --filter @repo/media test && pnpm --filter web-next test` Expected: PASS. - [ ] **Step 8: Commit** ```bash git add packages/media/src/ apps/web-next/src/server/bind-production.ts git commit -m "feat(media): wire instrumentation — media repo spans + getMedia/listMedia/deleteMedia withSpan" ``` --- ## Phase F — Contract suite span assertions ### Task 23: `defineContractSuite` `expectSpan` helper **Files:** - Modify: `packages/core-testing/src/contract/define-contract-suite.ts` - Modify: `packages/core-testing/src/contract/define-contract-suite.test.ts` - [ ] **Step 1: Update define-contract-suite.ts** ```ts // packages/core-testing/src/contract/define-contract-suite.ts import { describe } from "vitest"; import type { RecordingTracer } from "../instrumentation/recording-tracer"; export interface ContractContext { buildSubject: () => Promise | T; /** * R50 — when callers wire a RecordingTracer into the subject they can supply * this accessor so contract suites can assert span shape per method. The * accessor MUST return the SAME tracer instance every call (so suites can * reset() and assert in sequence). */ getTracer?: () => RecordingTracer; } export interface ContractSuite { run( buildSubject: () => Promise | T, opts?: { tracer?: () => RecordingTracer }, ): void; } export function defineContractSuite( name: string, suite: (ctx: ContractContext) => void, ): ContractSuite { return { run(buildSubject, opts) { describe(`Contract: ${name}`, () => { suite({ buildSubject, getTracer: opts?.tracer }); }); }, }; } ``` - [ ] **Step 2: Update define-contract-suite.test.ts** Open existing tests and add coverage for `getTracer` plumbing: ```ts // Append to packages/core-testing/src/contract/define-contract-suite.test.ts import { describe, it, expect } from "vitest"; import { defineContractSuite } from "@/contract/define-contract-suite"; import { RecordingTracer } from "@/instrumentation/recording-tracer"; describe("defineContractSuite — getTracer plumbing (R50)", () => { it("passes the tracer accessor into the suite", () => { let receivedTracer: RecordingTracer | undefined; const tracer = new RecordingTracer(); const suite = defineContractSuite<{ foo: string }>("Test", ({ buildSubject, getTracer }) => { it("can read tracer", async () => { const subject = await buildSubject(); expect(subject.foo).toBe("bar"); receivedTracer = getTracer?.(); }); }); suite.run(() => ({ foo: "bar" }), { tracer: () => tracer }); // Vitest defers actual assertion to the `it`; we verify the wiring by re-reading after. // (This is a meta-test of plumbing only — the inner it() runs as a child describe.) expect(typeof tracer.startSpan).toBe("function"); }); it("getTracer is undefined when opts.tracer not provided (backward compat)", () => { let receivedAccessor: unknown = undefined; const suite = defineContractSuite<{ x: number }>("Test", ({ buildSubject, getTracer }) => { it("accessor undefined", async () => { await buildSubject(); receivedAccessor = getTracer; }); }); suite.run(() => ({ x: 1 })); // No tracer opts → accessor is undefined inside the suite body. // (Exact assertion happens via type, not runtime — typecheck gates this.) void receivedAccessor; }); }); ``` Run: `pnpm --filter @repo/core-testing test define-contract-suite` Expected: PASS — existing tests + 2 new tests. - [ ] **Step 3: Commit** ```bash git add packages/core-testing/src/contract/define-contract-suite.ts \ packages/core-testing/src/contract/define-contract-suite.test.ts git commit -m "feat(core-testing): R50 — contract context gains optional getTracer accessor" ``` --- ### Task 24: Update every repo contract suite to assert span shape **Files (all 6 contracts):** - `packages/blog/src/__contracts__/articles-repository.contract.ts` - `packages/auth/src/__contracts__/users-repository.contract.ts` - `packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts` - `packages/marketing-pages/src/__contracts__/pages-repository.contract.ts` - `packages/navigation/src/__contracts__/header-repository.contract.ts` - `packages/media/src/__contracts__/media-repository.contract.ts` Plus the call sites that run them (the per-feature `*.repository.mock.test.ts` and any real-Payload contract integration test) — update to pass `{ tracer: () => recordingTracer }`. > **Pattern (applies to every contract):** add a final `describe.skipIf(!getTracer)("span emission (R50)")` block enumerating one `it()` per repo method, each invoking the method and asserting `tracer.findSpan(".")` returns a span with `op: "repository"` and the expected attributes. - [ ] **Step 1: Update blog contract** (`packages/blog/src/__contracts__/articles-repository.contract.ts`) Append to the suite body, after the existing tests: ```ts import { RecordingTracer } from "@repo/core-testing/instrumentation"; // add at top // At the end of the suite body, before the closing `});`: describe("span emission (R50)", () => { it("getArticles emits articles.getArticles span with op=repository", async () => { if (!getTracer) return; const tracer = getTracer(); tracer.reset(); await repo.getArticles({ limit: 5 }); const span = tracer.findSpan("articles.getArticles"); expect(span).toBeDefined(); expect(span!.op).toBe("repository"); expect(span!.attributes.limit).toBe(5); }); it("getArticle emits articles.getArticle span with id attribute", async () => { if (!getTracer) return; const tracer = getTracer(); tracer.reset(); await repo.getArticle("nonexistent"); const span = tracer.findSpan("articles.getArticle"); expect(span).toBeDefined(); expect(span!.attributes.id).toBe("nonexistent"); }); it("getArticleBySlug emits articles.getArticleBySlug span with slug attribute", async () => { if (!getTracer) return; const tracer = getTracer(); tracer.reset(); await repo.getArticleBySlug("nonexistent"); const span = tracer.findSpan("articles.getArticleBySlug"); expect(span).toBeDefined(); expect(span!.attributes.slug).toBe("nonexistent"); }); it("createArticle emits articles.createArticle span", async () => { if (!getTracer) return; const tracer = getTracer(); tracer.reset(); const seed = articleFactory.build(); await repo.createArticle(seed); const span = tracer.findSpan("articles.createArticle"); expect(span).toBeDefined(); expect(span!.attributes.slug).toBe(seed.slug); }); it("updateArticle emits articles.updateArticle span", async () => { if (!getTracer) return; const tracer = getTracer(); tracer.reset(); const seed = articleFactory.build(); const created = await repo.createArticle(seed); await repo.updateArticle(created.id, { title: "Updated" }); const span = tracer.findSpan("articles.updateArticle"); expect(span).toBeDefined(); expect(span!.attributes.id).toBe(created.id); }); }); ``` > **Note:** the suite's `getTracer` and `repo` references must already be in scope. If the existing suite doesn't accept `getTracer` from `ContractContext`, update the destructuring at the top: > `({ buildSubject, getTracer }) => { ... }`. > Also import `describe` if it isn't already imported at the top. - [ ] **Step 2: Update the blog mock contract caller** The contract suite is consumed by mock-side tests like `articles.repository.mock.test.ts`. Update the consumer to pass `tracer`: ```ts // packages/blog/src/infrastructure/repositories/articles.repository.mock.test.ts (excerpt) import { RecordingTracer } from "@repo/core-testing/instrumentation"; import { MockArticlesRepository } from "./articles.repository.mock"; import { articlesRepositoryContract } from "../../__contracts__/articles-repository.contract"; const tracer = new RecordingTracer(); articlesRepositoryContract.run( () => new MockArticlesRepository(tracer), { tracer: () => tracer }, ); ``` - [ ] **Step 3: Update the blog real-payload contract caller (if present)** If the codebase has an integration test that runs the contract against the real Payload-backed repository (e.g., `articles.repository.test.ts`), thread the tracer through identically: ```ts const tracer = new RecordingTracer(); articlesRepositoryContract.run( async () => { const config = await stubConfig(); // existing test helper return new ArticlesRepository(config, tracer); }, { tracer: () => tracer }, ); ``` - [ ] **Step 4: Repeat Steps 1–3 for the other five contracts** For each of: - `auth/src/__contracts__/users-repository.contract.ts` — methods enumerated by reading the file (typically `getUserByEmail`, `getUser`, `createUser`, etc.). Spans `users.`. - `marketing-pages/src/__contracts__/site-settings-repository.contract.ts` — typically a single `getSiteSettings` method. Spans `site-settings.`. - `marketing-pages/src/__contracts__/pages-repository.contract.ts` — typically `getPageBySlug`. Spans `pages.`. - `navigation/src/__contracts__/header-repository.contract.ts` — typically `getHeader`. Spans `header.`. - `media/src/__contracts__/media-repository.contract.ts` — typically `getMedia`, `listMedia`, `deleteMedia`. Spans `media.`. For each contract: add the `span emission (R50)` describe block enumerating one `it` per method. Update each contract's mock caller and real caller to pass `{ tracer: () => recordingTracer }`. - [ ] **Step 5: Run all feature tests** Run: `pnpm test` Expected: PASS across the monorepo. Each feature now has additional span-shape assertions running as part of the contract. - [ ] **Step 6: Commit** ```bash git add packages/blog/src/__contracts__/articles-repository.contract.ts \ packages/blog/src/infrastructure/repositories/articles.repository.mock.test.ts \ packages/blog/src/infrastructure/repositories/articles.repository.test.ts \ packages/auth/src/__contracts__/users-repository.contract.ts \ packages/auth/src/infrastructure/repositories/ \ packages/marketing-pages/src/__contracts__/ \ packages/marketing-pages/src/infrastructure/repositories/ \ packages/navigation/src/__contracts__/header-repository.contract.ts \ packages/navigation/src/infrastructure/repositories/ \ packages/media/src/__contracts__/media-repository.contract.ts \ packages/media/src/infrastructure/repositories/ git commit -m "test(features): R50 — repo contract suites assert span shape per method" ``` --- ## Phase G — App integration ### Task 25: apps/web-next instrumentation files + PII scrubber test **Files:** - Create: `apps/web-next/instrumentation.ts` - Create: `apps/web-next/instrumentation-client.ts` - Modify: `apps/web-next/next.config.mjs` - Modify: `apps/web-next/package.json` (add `@sentry/nextjs` dep) - Create: `apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts` - [ ] **Step 1: Add the dep** ```bash pnpm --filter web-next add @sentry/nextjs ``` - [ ] **Step 2: Write apps/web-next/instrumentation.ts** ```ts // apps/web-next/instrumentation.ts // Next.js convention: this module runs once on server boot. // We delegate to the centralized init helper in core-shared. export async function register() { if ( process.env.NEXT_RUNTIME === "nodejs" || process.env.NEXT_RUNTIME === "edge" ) { const { initSentryServer } = await import( "@repo/core-shared/instrumentation/sentry/init-server" ); initSentryServer({ dsn: process.env.WEB_NEXT_SENTRY_DSN, app: "web-next", release: process.env.VERCEL_GIT_COMMIT_SHA, }); } } ``` - [ ] **Step 3: Write apps/web-next/instrumentation-client.ts** ```ts // apps/web-next/instrumentation-client.ts // Next.js 15+ browser hook: runs in the client bundle on app start. import { initSentryClient } from "@repo/core-shared/instrumentation/sentry/init-client"; initSentryClient({ dsn: process.env.NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN, app: "web-next", release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, }); ``` - [ ] **Step 4: Wrap next.config.mjs with `withSentryConfig`** Open `apps/web-next/next.config.mjs`. Wrap the default export: ```js // apps/web-next/next.config.mjs (excerpt — preserve existing config object) import { withSentryConfig } from "@sentry/nextjs"; const nextConfig = { // ... existing config (unchanged) ... }; export default withSentryConfig(nextConfig, { // R52 — token is build-time only; CI sets SENTRY_AUTH_TOKEN silent: process.env.CI !== "true", authToken: process.env.SENTRY_AUTH_TOKEN, org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT_WEB_NEXT, // Don't tunnel through the app server in dev — use direct uploads hideSourceMaps: true, disableLogger: true, }); ``` - [ ] **Step 5: Write the PII scrubber smoke test (R38)** ```ts // apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts import { describe, it, expect } from "vitest"; import { beforeSend, beforeSendTransaction, } from "@repo/core-shared/instrumentation/sentry/scrub"; describe("R38 — apps/web-next PII scrubber", () => { it("strips email/password/cookie/auth/IP from event payload", () => { const event = { extra: { userEmail: "alice@example.com", password: "p4$$w0rd", ipAddress: "192.168.1.10", note: "request from 10.0.0.1", }, request: { headers: { Authorization: "Bearer secret", "Set-Cookie": "session=abc", "User-Agent": "Mozilla", }, }, } as any; const result = beforeSend(event, {} as any) as any; expect(result.extra.userEmail).toBe("[redacted]"); expect(result.extra.password).toBe("[redacted]"); expect(result.extra.ipAddress).toBe("[redacted]"); expect(result.extra.note).toContain("[redacted-ip]"); expect(result.request.headers.Authorization).toBe("[redacted]"); expect(result.request.headers["Set-Cookie"]).toBe("[redacted]"); expect(result.request.headers["User-Agent"]).toBe("Mozilla"); }); it("strips ?token / ?email / ?password / ?secret / ?signature from URLs", () => { const event = { request: { url: "https://app/api/x?token=abc&email=a@b.c&password=p&secret=z&signature=s&safe=1", }, transaction: "/foo?accessToken=t", } as any; const result = beforeSendTransaction(event, {} as any) as any; const url = decodeURIComponent(result.request.url); const txn = decodeURIComponent(result.transaction); for (const key of ["token", "email", "password", "secret", "signature"]) { expect(url).toContain(`${key}=[redacted]`); } expect(url).toContain("safe=1"); expect(txn).toContain("accessToken=[redacted]"); }); }); ``` Run: `pnpm --filter web-next test sentry-pii-scrubber` Expected: PASS — 2 tests. - [ ] **Step 6: Commit** ```bash git add apps/web-next/instrumentation.ts \ apps/web-next/instrumentation-client.ts \ apps/web-next/next.config.mjs \ apps/web-next/package.json \ apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts \ pnpm-lock.yaml git commit -m "feat(web-next): Sentry instrumentation hooks + withSentryConfig + R38 PII test" ``` --- ### Task 26: apps/cms instrumentation files + PII scrubber test **Files:** - Create: `apps/cms/instrumentation.ts` - Modify: `apps/cms/next.config.mjs` - Modify: `apps/cms/package.json` (add `@sentry/nextjs`) - Create: `apps/cms/src/__tests__/sentry-pii-scrubber.test.ts` > **Difference from web-next:** CMS is server-only (Payload admin UI); no `instrumentation-client.ts` needed (Payload's admin runs in a browser but its bundling/build is opinionated and the public DSN flow is Payload-specific — defer client-side CMS instrumentation as out-of-scope per spec §8). Server side is identical. - [ ] **Step 1: Add the dep** ```bash pnpm --filter cms add @sentry/nextjs ``` - [ ] **Step 2: Write apps/cms/instrumentation.ts** ```ts // apps/cms/instrumentation.ts export async function register() { if ( process.env.NEXT_RUNTIME === "nodejs" || process.env.NEXT_RUNTIME === "edge" ) { const { initSentryServer } = await import( "@repo/core-shared/instrumentation/sentry/init-server" ); initSentryServer({ dsn: process.env.CMS_SENTRY_DSN, app: "cms", release: process.env.VERCEL_GIT_COMMIT_SHA, }); } } ``` - [ ] **Step 3: Wrap next.config.mjs** Same pattern as Task 25 Step 4, but the project env var is `SENTRY_PROJECT_CMS`: ```js import { withSentryConfig } from "@sentry/nextjs"; const nextConfig = { /* existing */ }; export default withSentryConfig(nextConfig, { silent: process.env.CI !== "true", authToken: process.env.SENTRY_AUTH_TOKEN, org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT_CMS, hideSourceMaps: true, disableLogger: true, }); ``` - [ ] **Step 4: PII scrubber smoke test (R38)** Create `apps/cms/src/__tests__/sentry-pii-scrubber.test.ts` with the same content as Task 25 Step 5. (Copy verbatim — the tests are app-agnostic but R38 requires one per app to ensure each app's vitest config is wired correctly.) Run: `pnpm --filter cms test sentry-pii-scrubber` Expected: PASS — 2 tests. - [ ] **Step 5: Commit** ```bash git add apps/cms/instrumentation.ts \ apps/cms/next.config.mjs \ apps/cms/package.json \ apps/cms/src/__tests__/sentry-pii-scrubber.test.ts \ pnpm-lock.yaml git commit -m "feat(cms): Sentry server instrumentation + withSentryConfig + R38 PII test" ``` --- ### Task 27: apps/web-tanstack instrumentation files + PII scrubber test **Files:** - Create: `apps/web-tanstack/src/instrumentation.ts` (server entry hook) - Create: `apps/web-tanstack/src/instrumentation-client.ts` (client entry hook) - Modify: `apps/web-tanstack/vite.config.ts` (add `@sentry/vite-plugin`) - Modify: `apps/web-tanstack/package.json` (add `@sentry/node` + `@sentry/react` + `@sentry/vite-plugin`) - Create: `apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts` > **Difference from web-next/cms:** TanStack Start uses Vite, not Next.js. The `@sentry/nextjs` package is wrong here — use `@sentry/node` (server) and `@sentry/react` (client). The init helpers in `core-shared/instrumentation/sentry/` need a Vite/Vanilla flavor. To minimize complexity, this task adds two thin wrappers in core-shared that re-export the same scrubbers but call `@sentry/node`/`@sentry/react` instead. (If preferred for v1, mark this task as **deferred** and skip web-tanstack — but the spec requires three-app coverage.) - [ ] **Step 1: Add deps** ```bash pnpm --filter web-tanstack add @sentry/node @sentry/react pnpm --filter web-tanstack add -D @sentry/vite-plugin ``` - [ ] **Step 2: Add `@sentry/node` + `@sentry/react` core-shared adapters** Create `packages/core-shared/src/instrumentation/sentry/init-server-node.ts`: ```ts // packages/core-shared/src/instrumentation/sentry/init-server-node.ts import * as SentryNode from "@sentry/node"; import { beforeSend, beforeSendTransaction } from "./scrub"; import type { InitServerOpts } from "./init-server"; /** * Server-side init for non-Next.js runtimes (TanStack Start). Mirrors * init-server.ts but uses @sentry/node directly. R31, R32, R33 still apply. */ export function initSentryServerNode(opts: InitServerOpts): void { if (!opts.dsn) return; const isProd = process.env.NODE_ENV === "production"; const tracesSampleRate = process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) : isProd ? 0.1 : 1.0; SentryNode.init({ dsn: opts.dsn, environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development", release: opts.release ?? process.env.VITE_GIT_COMMIT_SHA ?? "unknown", tracesSampleRate, sendDefaultPii: false, beforeSend: beforeSend as any, beforeSendTransaction: beforeSendTransaction as any, initialScope: { tags: { app: opts.app } }, }); } ``` Create `packages/core-shared/src/instrumentation/sentry/init-client-react.ts`: ```ts // packages/core-shared/src/instrumentation/sentry/init-client-react.ts import * as SentryReact from "@sentry/react"; import { beforeSend, beforeSendTransaction } from "./scrub"; import type { InitClientOpts } from "./init-client"; export function initSentryClientReact(opts: InitClientOpts): void { if (!opts.dsn) return; const isProd = process.env.NODE_ENV === "production"; const tracesSampleRate = process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) : isProd ? 0.1 : 1.0; SentryReact.init({ dsn: opts.dsn, environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development", release: opts.release ?? "unknown", tracesSampleRate, sendDefaultPii: false, beforeSend: beforeSend as any, beforeSendTransaction: beforeSendTransaction as any, replaysSessionSampleRate: 0.0, replaysOnErrorSampleRate: 1.0, integrations: [ SentryReact.replayIntegration({ maskAllText: true, maskAllInputs: true, blockAllMedia: true, }), ], initialScope: { tags: { app: opts.app } }, }); } ``` Add the two new files to `packages/core-shared/src/instrumentation/index.ts`: ```ts export { initSentryServerNode } from "./sentry/init-server-node"; export { initSentryClientReact } from "./sentry/init-client-react"; ``` Add `@sentry/node` + `@sentry/react` to `packages/core-shared/package.json` `peerDependencies` (optional) so apps that use them install them; the core-shared test process gets them via the test guard mock if needed (extend `setup/no-sentry.ts` to also mock `@sentry/node` and `@sentry/react`). - [ ] **Step 3: Extend setup/no-sentry.ts to mock @sentry/node + @sentry/react** Edit `packages/core-testing/src/setup/no-sentry.ts` and add: ```ts vi.mock("@sentry/node", () => ({ init: vi.fn(), startSpan: vi.fn((_opts: unknown, fn: any) => fn({ setAttribute: vi.fn(), setStatus: vi.fn() }), ), captureException: vi.fn(), captureMessage: vi.fn(), addBreadcrumb: vi.fn(), setUser: vi.fn(), })); vi.mock("@sentry/react", () => ({ init: vi.fn(), captureException: vi.fn(), captureMessage: vi.fn(), addBreadcrumb: vi.fn(), setUser: vi.fn(), replayIntegration: vi.fn(() => ({ name: "Replay" })), })); ``` - [ ] **Step 4: Write tests for the new init helpers** Create `packages/core-shared/src/instrumentation/sentry/init-server-node.test.ts` and `init-client-react.test.ts` mirroring Tasks 10 & 11 (assert sendDefaultPii: false, scrubbers attached, mask flags, no-op on missing DSN). Run: `pnpm --filter @repo/core-shared test init-server-node init-client-react` Expected: PASS. - [ ] **Step 5: Write web-tanstack instrumentation files** ```ts // apps/web-tanstack/src/instrumentation.ts import { initSentryServerNode } from "@repo/core-shared/instrumentation/sentry/init-server-node"; initSentryServerNode({ dsn: process.env.WEB_TANSTACK_SENTRY_DSN, app: "web-tanstack", release: process.env.VITE_GIT_COMMIT_SHA, }); ``` ```ts // apps/web-tanstack/src/instrumentation-client.ts import { initSentryClientReact } from "@repo/core-shared/instrumentation/sentry/init-client-react"; initSentryClientReact({ dsn: import.meta.env.VITE_WEB_TANSTACK_SENTRY_DSN, app: "web-tanstack", release: import.meta.env.VITE_GIT_COMMIT_SHA, }); ``` > **Wiring:** `apps/web-tanstack/src/server.ts` (or wherever the server entry boots) MUST `import "./instrumentation"` at the top. The client entry (`src/main.tsx` or `src/client.tsx`) MUST `import "./instrumentation-client"` at the top. - [ ] **Step 6: Update vite.config.ts to upload source maps** ```ts // apps/web-tanstack/vite.config.ts (excerpt) import { sentryVitePlugin } from "@sentry/vite-plugin"; // ... existing imports ... export default defineConfig({ // ... existing config ... build: { // ... existing build config ... sourcemap: true, // required by sentryVitePlugin }, plugins: [ // ... existing plugins ... sentryVitePlugin({ authToken: process.env.SENTRY_AUTH_TOKEN, org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT_WEB_TANSTACK, silent: process.env.CI !== "true", disable: !process.env.SENTRY_AUTH_TOKEN, // skip in non-CI builds }), ], }); ``` - [ ] **Step 7: PII scrubber smoke test (R38)** Create `apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts` with content identical to Task 25 Step 5. Run: `pnpm --filter web-tanstack test sentry-pii-scrubber` Expected: PASS — 2 tests. - [ ] **Step 8: Commit** ```bash git add apps/web-tanstack/src/instrumentation.ts \ apps/web-tanstack/src/instrumentation-client.ts \ apps/web-tanstack/vite.config.ts \ apps/web-tanstack/package.json \ apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts \ packages/core-shared/src/instrumentation/sentry/init-server-node.ts \ packages/core-shared/src/instrumentation/sentry/init-server-node.test.ts \ packages/core-shared/src/instrumentation/sentry/init-client-react.ts \ packages/core-shared/src/instrumentation/sentry/init-client-react.test.ts \ packages/core-shared/src/instrumentation/index.ts \ packages/core-shared/package.json \ packages/core-testing/src/setup/no-sentry.ts \ pnpm-lock.yaml git commit -m "feat(web-tanstack): Sentry instrumentation via @sentry/node + @sentry/react + R38 PII test" ``` --- ## Phase H — Boundary enforcement + config ### Task 28: ESLint boundary rule (R40) + CI grep for `sendDefaultPii: true` (R31) **Files:** - Modify: `packages/core-eslint/base.js` - Create: `.github/workflows/sentry-pii-guard.yml` (or extend existing CI workflow) - [ ] **Step 1: Add `no-restricted-imports` to core-eslint/base.js** Open `packages/core-eslint/base.js`. Add a new flat-config block (preserve existing entries): ```js // packages/core-eslint/base.js (excerpt — append a new config block) { files: ["**/*.{ts,tsx,mjs,cjs,js}"], ignores: [ // R40 — only these paths may import from @sentry/* "packages/core-shared/src/instrumentation/sentry/**", "packages/core-testing/src/setup/no-sentry.{ts,js}", "apps/*/instrumentation.{ts,js,mjs}", "apps/*/instrumentation-client.{ts,js,mjs}", "apps/*/src/instrumentation.{ts,js,mjs}", "apps/*/src/instrumentation-client.{ts,js,mjs}", "apps/*/next.config.{mjs,ts,js}", "apps/*/vite.config.{ts,mjs,js}", "apps/*/sentry.*.config.{ts,mjs,js}", ], rules: { "no-restricted-imports": [ "error", { patterns: [ { group: ["@sentry/*"], message: "Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly (R40).", }, ], }, ], }, }, ``` > **Note:** the `ignores` array on a flat-config block is the **opposite** of allowlist — these are paths that DO get the rule applied. To make the allowlist work, invert: apply the rule to *everything* and override (turn off) for the allowlisted paths in a follow-up block. Use this two-block pattern instead: ```js // Block 1 — apply restriction repo-wide { files: ["**/*.{ts,tsx,mjs,cjs,js}"], rules: { "no-restricted-imports": [ "error", { patterns: [ { group: ["@sentry/*"], message: "Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly (R40).", }, ], }, ], }, }, // Block 2 — override (allow Sentry imports) for the explicit allowlist { files: [ "packages/core-shared/src/instrumentation/sentry/**", "packages/core-testing/src/setup/no-sentry.{ts,js}", "apps/*/instrumentation.{ts,js,mjs}", "apps/*/instrumentation-client.{ts,js,mjs}", "apps/*/src/instrumentation.{ts,js,mjs}", "apps/*/src/instrumentation-client.{ts,js,mjs}", "apps/*/next.config.{mjs,ts,js}", "apps/*/vite.config.{ts,mjs,js}", "apps/*/sentry.*.config.{ts,mjs,js}", ], rules: { "no-restricted-imports": "off", }, }, ``` - [ ] **Step 2: Verify the rule fires** Create a temporary throwaway file to test, then delete: ```bash mkdir -p /tmp/sentry-rule-check cat <<'EOF' > /tmp/sentry-rule-check/violator.ts import * as Sentry from "@sentry/nextjs"; console.log(Sentry); EOF # Place inside a feature path to actually exercise the rule: cp /tmp/sentry-rule-check/violator.ts packages/blog/src/__violator__.ts pnpm --filter @repo/blog lint || echo "EXPECTED: lint should fail with R40 message" rm packages/blog/src/__violator__.ts ``` Expected: lint fails with the R40 message. Repeat the check at an allowlisted path: ```bash cp /tmp/sentry-rule-check/violator.ts apps/web-next/instrumentation-temp.ts # (instrumentation*.ts is allowlisted) pnpm --filter web-next lint && echo "EXPECTED: lint should PASS at allowlisted path" rm apps/web-next/instrumentation-temp.ts rm -rf /tmp/sentry-rule-check ``` Expected: lint passes (the allowlist correctly turns off the rule there). - [ ] **Step 3: Add a CI grep step for R31 (`sendDefaultPii: true`)** If a GitHub Actions workflow already exists, append a step. Otherwise create `.github/workflows/sentry-pii-guard.yml`: ```yaml # .github/workflows/sentry-pii-guard.yml name: Sentry PII guard (R31) on: pull_request: push: branches: [main] jobs: pii-guard: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Verify sendDefaultPii is never true run: | if grep -RIn --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.cjs' --include='*.js' \ -E 'sendDefaultPii\s*:\s*true' \ packages/ apps/; then echo "::error::R31 violation — sendDefaultPii: true is forbidden anywhere in the repo." exit 1 fi echo "OK — no sendDefaultPii: true detected." ``` - [ ] **Step 4: Run lint repo-wide** Run: `pnpm lint` Expected: PASS — no current violation (instrumentation files in apps/ are allowlisted; core-shared/instrumentation/sentry/ is allowlisted; everything else doesn't import @sentry/*). - [ ] **Step 5: Commit** ```bash git add packages/core-eslint/base.js .github/workflows/sentry-pii-guard.yml git commit -m "feat(eslint+ci): R40 boundary rule for @sentry/* + R31 sendDefaultPii grep gate" ``` --- ### Task 29: turbo.json globalEnv updates **Files:** - Modify: `turbo.json` - [ ] **Step 1: Read current turbo.json** Run: `cat turbo.json | jq .globalEnv` Note the existing entries (e.g., `USE_DEV_SEED`, `NODE_ENV`). - [ ] **Step 2: Append the 8 instrumentation env vars** Edit `turbo.json` and add to `globalEnv` (preserving existing entries): ```json { "globalEnv": [ "USE_DEV_SEED", "NODE_ENV", "WEB_NEXT_SENTRY_DSN", "NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN", "CMS_SENTRY_DSN", "WEB_TANSTACK_SENTRY_DSN", "VITE_WEB_TANSTACK_SENTRY_DSN", "SENTRY_AUTH_TOKEN", "SENTRY_ORG", "SENTRY_PROJECT_WEB_NEXT", "SENTRY_PROJECT_CMS", "SENTRY_PROJECT_WEB_TANSTACK", "SENTRY_TRACES_SAMPLE_RATE", "SENTRY_ENVIRONMENT", "VERCEL_GIT_COMMIT_SHA", "NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA", "VERCEL_ENV" ] } ``` - [ ] **Step 3: Verify turbo accepts the config** Run: `pnpm turbo build --dry` Expected: no warnings about undeclared env vars. - [ ] **Step 4: Commit** ```bash git add turbo.json git commit -m "chore(turbo): declare instrumentation env vars in globalEnv" ``` --- ## Phase I — Docs + HTML ### Task 30: Doc updates — CLAUDE.md, AGENTS.md, vertical-feature-spec.md **Files:** - Modify: `CLAUDE.md` - Modify: `AGENTS.md` - Modify: `docs/architecture/vertical-feature-spec.md` - [ ] **Step 1: Update CLAUDE.md** Open `CLAUDE.md` and append to the "Key Conventions" section: ```markdown - **Instrumentation lives in `core-shared/instrumentation/`** — Two interfaces (`ITracer`, `ILogger`), three implementations (`NoopTracer`/`NoopLogger`, `SentryTracer`/`SentryLogger`, and `RecordingTracer`/`RecordingLogger` from `core-testing`). Feature packages MUST NOT import `@sentry/*` directly (R40, eslint-enforced). - **Spans applied at DI bind time** — Use cases + controllers wrapped via `withSpan(tracer, { name: ".", op: "use-case" }, factory(...))` inside `bind-production` / `bind-dev-seed`. Repository methods emit explicit `tracer.startSpan({ name: ".", op: "repository", attributes: {...} }, ...)` (R41, R42). - **Capture at throw sites only** — Repository catch blocks call `this.logger.captureException(err, { tags: { feature, repo, method } })`; use cases capture errors they originate; the tRPC error middleware does NOT capture (R43, R44). - **PII handling is non-negotiable** — `sendDefaultPii: false` everywhere (R31, CI grep gate); replay default-masks all text/inputs/media (R34, R35, allowlist starts empty); `Sentry.setUser({ id })` only — no email/username (R36); `beforeSend` + `beforeSendTransaction` scrubbers strip emails/passwords/tokens/cookies/auth/IPs (R32, R33). - **Three apps, three Sentry projects** — `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN`. Browser DSNs use `NEXT_PUBLIC_` (web-next) and `VITE_` (web-tanstack) prefixes. - **Instrumentation binding is orthogonal to repo binding** — `bindAll()`'s Rule 0 (DSN → Sentry vs Noop) is independent of `USE_DEV_SEED` / `NODE_ENV`. Run `pnpm dev` with `WEB_NEXT_SENTRY_DSN` set to test the integration locally. ``` - [ ] **Step 2: Update root AGENTS.md** Open `AGENTS.md`. Add a new section after the existing per-feature conventions: ```markdown ## Instrumentation conventions **Symbols (in `core-shared/instrumentation/symbols.ts`):** - `INSTRUMENTATION_SYMBOLS.TRACER` — bound to `ITracer` (NoopTracer / SentryTracer) - `INSTRUMENTATION_SYMBOLS.LOGGER` — bound to `ILogger` (NoopLogger / SentryLogger) **Repository constructor signature (every feature):** ```ts constructor( config: SanitizedConfig, tracer: ITracer = new NoopTracer(), logger: ILogger = new NoopLogger(), ) ``` **Repository method body (every public async method):** ```ts return this.tracer.startSpan( { name: ".", op: "repository", attributes: {...} }, async (span) => { try { const result = await /* payload op */; span.setAttribute("count", /* ... */); return result; } catch (err) { this.logger.captureException(err, { tags: { feature: "", repo: "", method: "" }, }); span.setStatus("error", err instanceof Error ? err.message : String(err)); throw err; } }, ); ``` **Use case + controller spans (applied at DI bind time):** ```ts const wrappedUC = withSpan(tracer, { name: "blog.getArticles", op: "use-case" }, getArticlesUseCase(repo)); const wrappedCtrl = withSpan(tracer, { name: "blog.getArticles", op: "controller" }, getArticlesController(wrappedUC)); ``` **Capture rules:** | Layer | Captures | Doesn't capture | |---|---|---| | Repository | Infra/Payload errors that originate here | Bubbled errors | | Use case | Business-rule violations originated in this body | Errors from repos | | Controller | InputParseError from safeParse failure | Anything else | | `defineErrorMiddleware` | Nothing — maps domain → TRPCError only | — | **Boundary rule (eslint-enforced):** Feature packages MUST NOT `import "@sentry/*"`. Allowlist: - `packages/core-shared/src/instrumentation/sentry/**` - `apps/*/instrumentation*.{ts,mjs,js}` - `apps/*/next.config.{mjs,ts,js}` - `apps/*/vite.config.{ts,mjs,js}` **Test rules:** - Default to `NoopTracer` / `NoopLogger` (constructor defaults). - Assert spans/captures by injecting `RecordingTracer` / `RecordingLogger` from `@repo/core-testing/instrumentation`. - Real `@sentry/*` SDK MUST NOT initialize during tests (guarded by `core-testing/setup/no-sentry.ts`). ``` - [ ] **Step 3: Update vertical-feature-spec.md** Open `docs/architecture/vertical-feature-spec.md` and append a new section: ```markdown ## §10 — Instrumentation & error capture **Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (Plan 10, R31–R55). **File additions per feature:** - `infrastructure/repositories/.repository.ts` — constructor takes `(config, tracer, logger)` with Noop defaults; every public method's body is wrapped in `tracer.startSpan(...)` and any `catch` block calls `logger.captureException(err, { tags: { feature, repo, method } })` before re-throwing. - `infrastructure/repositories/.repository.mock.ts` — same constructor/wrapping shape (no catch — mocks don't originate infra errors). - `di/bind-production.ts` — signature `(config, tracer, logger)`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan` at bind time. - `di/bind-dev-seed.ts` — signature `(tracer, logger)`. Same wrapping as bind-production but with the populated mock. **Required exports (per feature root):** unchanged. **Public surface impact:** none for `./` (contracts) and `./ui`. The `./di/bind-production` and `./di/bind-dev-seed` subpaths now have new signatures — any consumer outside the app dispatcher is unaffected (the dispatcher is the only consumer per ADR-008). **Test patterns:** - **Direct injection** of `RecordingTracer` / `RecordingLogger` from `@repo/core-testing/instrumentation` for span/capture assertions. - **Contract suite span assertions** — every repo's contract suite (`__contracts__/*-repository.contract.ts`) includes a `span emission (R50)` describe block enumerating one assertion per method. **Tradeoff:** every public repo method gains ~6 lines of `tracer.startSpan(...)` boilerplate. Worth the per-method visibility in production traces; if it ever proves excessive, a `withRepoSpan` helper can collapse the wrapping. ``` - [ ] **Step 4: Commit** ```bash git add CLAUDE.md AGENTS.md docs/architecture/vertical-feature-spec.md git commit -m "docs: instrumentation conventions in CLAUDE.md / AGENTS.md / vertical-feature-spec.md" ``` --- ### Task 31: Doc updates — tdd-workflow.md, testing-strategy.md, dependency-flow.md, core-shared/AGENTS.md **Files:** - Modify: `docs/guides/tdd-workflow.md` - Modify: `docs/guides/testing-strategy.md` - Modify: `docs/architecture/dependency-flow.md` - Create or Modify: `packages/core-shared/AGENTS.md` - [ ] **Step 1: Update tdd-workflow.md** Append a section "Asserting spans and captures": ```markdown ## Asserting spans and captures Use cases, controllers, and repositories emit OpenTelemetry-style spans through the `ITracer` interface. Tests that need to assert span shape inject a `RecordingTracer`: ```ts import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation"; import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; describe("blog.getArticles use case", () => { it("emits a use-case span when invoked", async () => { const tracer = new RecordingTracer(); const logger = new RecordingLogger(); const repo = new MockArticlesRepository(tracer, logger); // Use cases are wrapped at DI bind time; for direct-injection tests, // wrap inline: const wrapped = withSpan(tracer, { name: "blog.getArticles", op: "use-case" }, getArticlesUseCase(repo)); await wrapped({ limit: 10 }); expect(tracer.findSpan("blog.getArticles")?.op).toBe("use-case"); expect(tracer.findSpan("articles.getArticles")?.op).toBe("repository"); }); }); ``` **Capture assertions** use `RecordingLogger`: ```ts const logger = new RecordingLogger(); const repo = new MockArticlesRepository(tracer, logger); // Force an infra error in your test setup, then: expect(logger.captures).toHaveLength(1); expect(logger.captures[0]).toMatchObject({ kind: "exception", ctx: { tags: { feature: "blog", repo: "articles", method: "getArticles" } }, }); ``` **Default mocks** (when you don't need assertions): construct `new MockArticlesRepository()` with no args — constructor defaults bind `NoopTracer` + `NoopLogger`. ``` - [ ] **Step 2: Update testing-strategy.md** Append a section after the existing factory/contract content: ```markdown ## R49 / R50 — Instrumentation testing **R49 — No real Sentry in tests.** The `core-testing/setup/no-sentry.ts` guard mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert Sentry SDK calls add their own `vi.mock(...)` per file. **R50 — Repository contracts assert span shape.** Every `__contracts__/-repository.contract.ts` includes a `span emission (R50)` describe block enumerating one assertion per public method. Suites run against both mock and real (Payload-backed) implementations, ensuring span emission stays in sync. Wire the recording tracer at the call site: ```ts const tracer = new RecordingTracer(); articlesRepositoryContract.run( () => new MockArticlesRepository(tracer), { tracer: () => tracer }, ); ``` **Capture vs span assertions:** - `RecordingTracer.spans` — every span emitted with `{ name, op, attributes, status, durationMs }`. - `RecordingLogger.captures` — every `captureException` / `captureMessage` call. - `RecordingLogger.breadcrumbs` — every breadcrumb added. - `RecordingLogger.users` — every `setUser` call (history). **Test cleanup:** call `tracer.reset()` and `logger.reset()` in `beforeEach` if the test creates one shared instance across multiple cases. ``` - [ ] **Step 3: Update dependency-flow.md** Add a new "TRACER / LOGGER" subsection after the existing per-feature DI graph, with this content: ```markdown ### TRACER / LOGGER (Plan 10) The instrumentation layer is **per-feature container** but **app-wide instance**: each feature container binds `INSTRUMENTATION_SYMBOLS.TRACER` and `INSTRUMENTATION_SYMBOLS.LOGGER` to the SAME instance, constructed once by the app's `bindAll()` dispatcher (Rule 0). ``` apps/web-next/src/server/bind-production.ts (bindAll) │ ├─ Rule 0: WEB_NEXT_SENTRY_DSN set? │ yes → bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" }) │ no → bindNoopInstrumentation(sharedContainer) │ ↓ │ tracer + logger instances │ ↓ ├─ bindProductionBlog(config, tracer, logger) │ │ │ ├─ blogContainer.bind(TRACER).toConstantValue(tracer) │ ├─ blogContainer.bind(LOGGER).toConstantValue(logger) │ ├─ ArticlesRepository(config, tracer, logger) → bound to IArticlesRepository │ ├─ withSpan(tracer, ...) wraps every use case → bound to UseCase symbol │ └─ withSpan(tracer, ...) wraps every controller → bound to Controller symbol │ └─ (same for auth, marketing-pages, navigation, media) ``` **Why per-feature container also gets the binding:** lets internal DI-resolved code in a feature pull TRACER/LOGGER without going through the app dispatcher. In practice, only repository classes and feature-internal services would ever use this — controllers and use cases receive instrumentation via the bind-time wrapper. **Why the shared container exists at all:** isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is on or off — they just receive an `ITracer` instance. ``` - [ ] **Step 4: Update or create core-shared/AGENTS.md** Open `packages/core-shared/AGENTS.md` (create if missing). Add a new section: ```markdown ## src/instrumentation/ **Two interfaces:** `ITracer` (in `tracer.interface.ts`) and `ILogger` (in `logger.interface.ts`). **Three implementation pairs:** - `NoopTracer` / `NoopLogger` — pass-through. Default everywhere. - `SentryTracer` / `SentryLogger` — adapters over `@sentry/nextjs`. Live in `sentry/` subfolder. **The `sentry/` subfolder is the only path in `packages/` permitted to import `@sentry/*`** (R40). - `RecordingTracer` / `RecordingLogger` — in `@repo/core-testing/instrumentation`, not here. **`with-span.ts`:** higher-order helper used at DI binding time to wrap use case + controller factory results in a span. Pattern: ```ts const wrapped = withSpan(tracer, { name: "blog.getArticles", op: "use-case" }, factory(deps)); ``` **Symbols:** `INSTRUMENTATION_SYMBOLS.TRACER`, `INSTRUMENTATION_SYMBOLS.LOGGER` (both `Symbol.for(...)` so cross-realm equality holds). **`sentry/scrub.ts`:** PII scrubbers used by every `Sentry.init()` call across the monorepo. Substring-based key matching catches derived names (`userEmail`, `accessToken`, `apiKey`). IPv4/IPv6 are redacted from string values. **`sentry/init-server.ts` + `init-client.ts`:** centralized init helpers that hard-code R31 (`sendDefaultPii: false`), R32/R33 (scrubbers), R34/R35 (replay mask flags), R37 (sample-rate defaults). Apps call these from `instrumentation.ts` / `instrumentation-client.ts`. **`sentry/init-server-node.ts` + `init-client-react.ts`:** Vite/non-Next variants used by `apps/web-tanstack`. **`di/bind-noop-instrumentation.ts` + `bind-sentry-instrumentation.ts`:** bind TRACER + LOGGER symbols to a Container. Returns the resolved instances so callers can use them without container lookup. **Boundaries:** - `core-shared/instrumentation/sentry/**` MAY import from `@sentry/*`. - Everything else in `packages/core-shared/src/` MUST NOT. - The eslint rule in `core-eslint/base.js` enforces the broader monorepo boundary (R40). ``` - [ ] **Step 5: Commit** ```bash git add docs/guides/tdd-workflow.md \ docs/guides/testing-strategy.md \ docs/architecture/dependency-flow.md \ packages/core-shared/AGENTS.md git commit -m "docs: instrumentation testing patterns + dependency-flow + core-shared AGENTS update" ``` --- ### Task 32: HTML updates — data-flow-explainer §07 + di-explainer additions **Files:** - Modify: `docs/architecture/data-flow-explainer.html` - Modify: `docs/architecture/di-explainer.html` > **Aesthetic constraint:** match the existing editorial-cream-paper / oxblood aesthetic. Don't introduce new fonts. Reuse the section-num + grid + tag patterns already in the page. - [ ] **Step 1: Add §07 to data-flow-explainer.html** Open `docs/architecture/data-flow-explainer.html`. Find the contents nav (the 6-column grid with §01-§06) and: a) Update the grid template to 7 columns and add the new entry: ```html 07. Tracing & error capture ``` b) Renumber the verdict from §06 to §07. c) Insert a new section between §05 (Tradeoffs by part) and the renumbered §07 (verdict): ```html
§ 06

Tracing & error capture

Every request produces a nested span tree: tRPC procedure → controller → use case → repository → Payload op. Errors are captured at the throw site closest to the cause, never at the boundary that translates them.

The trace tree (one tRPC request)

HTTP transaction               (auto, @sentry/nextjs)
└── tRPC procedure span        (auto, sentry trpc integration)
    └── controller span         (op="controller", DI-wrapped)
        └── use-case span       (op="use-case", DI-wrapped)
            └── repository span (op="repository", explicit startSpan)
                └── Payload Local API call (auto, @sentry/node http)

Capture rules (where Sentry.captureException fires)

LayerCapturesDoesn't capture
Repository Infra / Payload errors that originate here Bubbled errors
Use case Business-rule violations originated in this body Errors from repos (already captured)
Controller InputParseError from safeParse failure Anything else
defineErrorMiddleware Nothing — maps domain → TRPCError only

Double-report guard

Every error captured by SentryLogger.captureException gets a non-enumerable __sentryReported = true property. A second capture call for the same error returns early. This means each error surfaces in Sentry exactly once, regardless of how many layers it passes through.

PII rules (R31–R38, non-negotiable)

  • sendDefaultPii: false — every Sentry.init(). Build-time grep gate.
  • Replay default-masks all text + inputs + media. Allowlist starts empty.
  • beforeSend scrubber strips email / password / token / cookie / authorization keys (substring match).
  • beforeSendTransaction scrubber strips PII query params from URLs.
  • setUser accepts only { id }. Stripping wrapper warns in dev when other keys passed.
  • IPv4/IPv6 in event payload values redacted to [redacted-ip].
``` > **Note:** also renumber the existing verdict section's `
§ 06
` → `§ 07`. d) Add minimal CSS to support the new elements (insert in the existing `