Files
agentic-dev/docs/superpowers/plans/2026-05-06-plan-10-instrumentation-sentry.md
Danijel Martinek 77a97dac9e docs(plan): Plan 10 — instrumentation + Sentry logging implementation
33-task TDD plan implementing the R31–R55 spec across 9 phases:
foundation interfaces (NoopTracer/NoopLogger/withSpan), Sentry adapters
(SentryTracer/SentryLogger with __sentryReported double-report guard,
beforeSend + beforeSendTransaction PII scrubbers), DI binders + bindAll
Rule 0 dispatcher (orthogonal to USE_DEV_SEED/NODE_ENV), test
infrastructure (RecordingTracer/RecordingLogger + no-sentry guard),
per-feature wiring for all 5 features (blog/auth/marketing-pages/
navigation/media), contract suite span assertions, three-app integration
(web-next/cms/web-tanstack including @sentry/node + @sentry/react +
@sentry/vite-plugin variants), ESLint boundary rule + CI grep gate, and
docs + HTML updates (data-flow-explainer §06, di-explainer
instrumentation symbols, ADR-014).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:38:49 +02:00

184 KiB
Raw Blame History

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 (R31R55).


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/<feature>/src/di/bind-production.ts — accept (config, tracer, logger), wrap factories with withSpan
  • packages/<feature>/src/di/bind-dev-seed.ts — accept (tracer, logger), wrap factories
  • packages/<feature>/src/di/symbols.ts — re-export shared TRACER/LOGGER (or bind directly to feature container)
  • packages/<feature>/src/infrastructure/repositories/<x>.repository.ts — constructor takes tracer, logger; every method wraps body in tracer.startSpan(...)
  • packages/<feature>/src/infrastructure/repositories/<x>.repository.mock.ts — same constructor signature with Noop defaults; same startSpan wrapping
  • packages/<feature>/src/__contracts__/<x>-repository.contract.ts — assert span emission per method (R50)

Modified (apps)

  • apps/web-next/src/server/bind-production.tsbindAll() 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.jsonglobalEnv 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 16 (scaffold, interfaces, Noops, withSpan, symbols)
  • Phase B — Sentry adapters: Tasks 711 (SentryTracer, SentryLogger, scrubbers, init helpers)
  • Phase C — DI binders: Tasks 1214 (bindNoop, bindSentry, bindAll dispatcher)
  • Phase D — Test infra: Tasks 1517 (RecordingTracer, RecordingLogger, vitest setup)
  • Phase E — Per-feature wiring: Tasks 1822 (blog pilot + auth + marketing-pages + navigation + media)
  • Phase F — Contract + factory upgrades: Tasks 2324 (defineContractSuite expectSpan, contract suite updates)
  • Phase G — App integration: Tasks 2527 (web-next, cms, web-tanstack)
  • Phase H — Boundary + config: Tasks 2829 (ESLint rule + CI grep + turbo.json)
  • Phase I — Docs + HTML: Tasks 3033 (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

# 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
# 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
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

// 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<string, AttributeValue>;
};

export interface ISpan {
  setAttribute(key: string, value: AttributeValue): void;
  setStatus(status: "ok" | "error", message?: string): void;
}

export interface ITracer {
  startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T>;
}
  • Step 2: Verify it compiles

Run: pnpm --filter @repo/core-shared build Expected: build succeeds; new file present in dist/.

  • Step 3: Commit
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

// 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
// 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<T>(_opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
    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
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

// packages/core-shared/src/instrumentation/logger.interface.ts

export type Breadcrumb = {
  category: string;
  message: string;
  level?: "info" | "warning" | "error";
  data?: Record<string, unknown>;
};

export type CaptureContext = {
  tags?: Record<string, string>;
  extras?: Record<string, unknown>;
  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
// 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
// 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
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

// 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
// packages/core-shared/src/instrumentation/with-span.ts
import type { ITracer, SpanOpts } from "./tracer.interface";

export function withSpan<Args extends unknown[], R>(
  tracer: ITracer,
  opts: SpanOpts | ((args: Args) => SpanOpts),
  fn: (...args: Args) => Promise<R>,
): (...args: Args) => Promise<R> {
  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
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

// 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
// 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
// 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:

{
  "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:

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
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.

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

// 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
// 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<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
    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
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

// 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
// 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<string, unknown>)[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<string, unknown>;
    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
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

// 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
// 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
// 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<string, unknown> = {};
    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
      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
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

// 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
// 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
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

// 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
// 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
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

// 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<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER);
    const logger = c.get<ILogger>(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
// 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<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
  container.bind<ILogger>(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
// 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
// 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<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
  container.bind<ILogger>(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:

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
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():

// 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<void> {
  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<void> {
  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<void> {
  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
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):

// At the top of the file, extend the existing vi.mock list:
vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@repo/core-shared/instrumentation")>();
  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
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

// 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
// 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<string, AttributeValue>;
  status: "ok" | "error";
  statusMessage?: string;
  durationMs: number;
};

export class RecordingTracer implements ITracer {
  spans: RecordedSpan[] = [];

  async startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
    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
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

// 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
// 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
// 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:

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:

{
  "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
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
// 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:

import "./no-sentry";

Edit packages/core-testing/src/setup/node.ts — add at the top:

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:

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
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:

    async findX(args): Promise<R> {
      return this.tracer.startSpan(
        { name: "<entity>.<method>", 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: "<feature>", repo: "<entity>", method: "<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:

    async findX(args): Promise<R> {
      return this.tracer.startSpan(
        { name: "<entity>.<method>", 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: "<feature>.<useCase>", 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)
// 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<Article | undefined> {
    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<Article | undefined> {
    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<Article[]> {
    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<string, { equals: string }> = {};
          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<Article> {
    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<Article>,
  ): Promise<Article | undefined> {
    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
// 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<Article | undefined> {
    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<Article | undefined> {
    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<Article[]> {
    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<Article> {
    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<Article>,
  ): Promise<Article | undefined> {
    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
// 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<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
  blogContainer.bind<ILogger>(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
// 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<void> {
  if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
    blogContainer.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
  }
  if (blogContainer.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
    blogContainer.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
  }
  blogContainer.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
  blogContainer.bind<ILogger>(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<IArticlesRepository>(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:

// apps/web-next/src/server/bind-production.ts (excerpt)
export async function bindAllProduction(): Promise<void> {
  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<void> {
  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:

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
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.<method>. 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:

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.<method>", op: "repository", attributes: {...} }, ...). Add catch block calling this.logger.captureException(err, { tags: { feature: "auth", repo: "users", method: "<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
// 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
// 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.<method> 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
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.<method> and pages.<method>.

  • Step 3: Update bind-production.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

// 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
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.<method>.

  • 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

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
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.<method>. 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

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
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

// packages/core-testing/src/contract/define-contract-suite.ts
import { describe } from "vitest";
import type { RecordingTracer } from "../instrumentation/recording-tracer";

export interface ContractContext<T> {
  buildSubject: () => Promise<T> | 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<T> {
  run(
    buildSubject: () => Promise<T> | T,
    opts?: { tracer?: () => RecordingTracer },
  ): void;
}

export function defineContractSuite<T>(
  name: string,
  suite: (ctx: ContractContext<T>) => void,
): ContractSuite<T> {
  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:

// 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
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("<entity>.<method>") 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:

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:

// 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:

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 13 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.<method>.
  • marketing-pages/src/__contracts__/site-settings-repository.contract.ts — typically a single getSiteSettings method. Spans site-settings.<method>.
  • marketing-pages/src/__contracts__/pages-repository.contract.ts — typically getPageBySlug. Spans pages.<method>.
  • navigation/src/__contracts__/header-repository.contract.ts — typically getHeader. Spans header.<method>.
  • media/src/__contracts__/media-repository.contract.ts — typically getMedia, listMedia, deleteMedia. Spans media.<method>.

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
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

pnpm --filter web-next add @sentry/nextjs
  • Step 2: Write apps/web-next/instrumentation.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
// 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:

// 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)
// 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
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
pnpm --filter cms add @sentry/nextjs
  • Step 2: Write apps/cms/instrumentation.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:

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
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
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:

// 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:

// 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:

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:

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
// 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,
});
// 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
// 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
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):

// 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:

// 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:

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:

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:

# .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
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):

{
  "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
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:

- **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: "<feature>.<useCase>", op: "use-case" }, factory(...))` inside `bind-production` / `bind-dev-seed`. Repository methods emit explicit `tracer.startSpan({ name: "<entity>.<method>", 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:

## 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):

return this.tracer.startSpan(
  { name: "<entity>.<method>", op: "repository", attributes: {...} },
  async (span) => {
    try {
      const result = await /* payload op */;
      span.setAttribute("count", /* ... */);
      return result;
    } catch (err) {
      this.logger.captureException(err, {
        tags: { feature: "<feature>", repo: "<entity>", method: "<method>" },
      });
      span.setStatus("error", err instanceof Error ? err.message : String(err));
      throw err;
    }
  },
);

Use case + controller spans (applied at DI bind time):

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, R31R55).

**File additions per feature:**

- `infrastructure/repositories/<entity>.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/<entity>.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
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":

## 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:

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__/<x>-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:

## 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:

<a href="#tracing">07. Tracing & error capture</a>

b) Renumber the verdict from §06 to §07.

c) Insert a new section between §05 (Tradeoffs by part) and the renumbered §07 (verdict):

<!-- ─── 06 ─ TRACING & ERROR CAPTURE ─── -->
<section id="tracing" class="section">
  <div class="section-num">§ 06</div>
  <h2>Tracing & error capture</h2>
  <p class="lead">
    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.
  </p>

  <h3>The trace tree (one tRPC request)</h3>
  <pre class="trace-tree"><code>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)</code></pre>

  <h3>Capture rules (where Sentry.captureException fires)</h3>
  <table class="capture-rules">
    <thead>
      <tr><th>Layer</th><th>Captures</th><th>Doesn't capture</th></tr>
    </thead>
    <tbody>
      <tr>
        <td>Repository</td>
        <td>Infra / Payload errors that originate here</td>
        <td>Bubbled errors</td>
      </tr>
      <tr>
        <td>Use case</td>
        <td>Business-rule violations originated in this body</td>
        <td>Errors from repos (already captured)</td>
      </tr>
      <tr>
        <td>Controller</td>
        <td><code>InputParseError</code> from safeParse failure</td>
        <td>Anything else</td>
      </tr>
      <tr>
        <td><code>defineErrorMiddleware</code></td>
        <td><strong>Nothing</strong> — maps domain → TRPCError only</td>
        <td></td>
      </tr>
    </tbody>
  </table>

  <h3>Double-report guard</h3>
  <p>
    Every error captured by <code>SentryLogger.captureException</code> gets a
    non-enumerable <code>__sentryReported = true</code> 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.
  </p>

  <h3>PII rules (R31R38, non-negotiable)</h3>
  <ul class="pii-rules">
    <li><code>sendDefaultPii: false</code> — every <code>Sentry.init()</code>. Build-time grep gate.</li>
    <li>Replay <strong>default-masks all text + inputs + media</strong>. Allowlist starts empty.</li>
    <li><code>beforeSend</code> scrubber strips email / password / token / cookie / authorization keys (substring match).</li>
    <li><code>beforeSendTransaction</code> scrubber strips PII query params from URLs.</li>
    <li><code>setUser</code> accepts only <code>{ id }</code>. Stripping wrapper warns in dev when other keys passed.</li>
    <li>IPv4/IPv6 in event payload values redacted to <code>[redacted-ip]</code>.</li>
  </ul>
</section>

Note: also renumber the existing verdict section's <div class="section-num">§ 06</div>§ 07.

d) Add minimal CSS to support the new elements (insert in the existing <style> block):

.trace-tree {
  background: var(--paper-tone, #f4ede3);
  padding: 1.25rem 1.5rem;
  border-radius: 4px;
  overflow-x: auto;
  font-family: "JetBrains Mono", monospace;
  font-size: 0.85rem;
  line-height: 1.6;
}
.capture-rules {
  border-collapse: collapse;
  width: 100%;
  margin: 1.5rem 0;
}
.capture-rules th, .capture-rules td {
  padding: 0.6rem 0.85rem;
  text-align: left;
  border-bottom: 1px solid var(--rule, rgba(80,30,30,.18));
}
.capture-rules th { font-family: "Fraunces", serif; font-weight: 500; }
.pii-rules {
  display: grid;
  gap: 0.5rem;
  padding-left: 1.2rem;
}
.pii-rules li {
  font-size: 0.95rem;
}
  • Step 2: Update di-explainer.html with the new symbols + binders

Open docs/architecture/di-explainer.html. Find the section that documents existing symbols (typically §02 or §03 depending on numbering). Append a card or sub-section:

<section id="instrumentation-symbols" class="section">
  <div class="section-num">§ 0X</div>
  <h2>Instrumentation symbols (Plan 10)</h2>

  <h3><code>INSTRUMENTATION_SYMBOLS.TRACER</code></h3>
  <p>
    Bound by either <code>bindNoopInstrumentation</code> or
    <code>bindSentryInstrumentation</code> to <code>NoopTracer</code> or
    <code>SentryTracer</code> respectively. Decided by Rule 0 of
    <code>bindAll()</code>: DSN env present → Sentry; otherwise Noop.
    Orthogonal to <code>USE_DEV_SEED</code> / <code>NODE_ENV</code>.
  </p>

  <h3><code>INSTRUMENTATION_SYMBOLS.LOGGER</code></h3>
  <p>
    Same rule, same lifecycle. <code>NoopLogger</code> in the absence of a DSN;
    <code>SentryLogger</code> when DSN is set. The Sentry adapter applies the
    <code>__sentryReported</code> double-report guard internally — call sites
    don't manage the flag.
  </p>

  <h3>Wiring path</h3>
  <pre><code>bindAll()
  └─ resolveInstrumentation()         ← Rule 0
       └─ Noop or Sentry binders      ← bind to sharedContainer
  └─ bindProductionX(config, tracer, logger)
       └─ feature container also binds TRACER + LOGGER
       └─ withSpan(tracer, ...) at every use case + controller</code></pre>
</section>

Update the contents nav and renumber subsequent sections accordingly.

  • Step 3: Open both files in browser to verify
open docs/architecture/data-flow-explainer.html
open docs/architecture/di-explainer.html

Verify:

  • §06 (Tracing & error capture) renders with the trace-tree code block, capture-rules table, and pii-rules list.

  • Verdict is now §07 with correct numbering.

  • di-explainer shows the new instrumentation symbols and wiring diagram.

  • Step 4: Commit

git add docs/architecture/data-flow-explainer.html docs/architecture/di-explainer.html
git commit -m "docs(html): data-flow-explainer §06 tracing + di-explainer instrumentation symbols"

Task 33: ADR-014 final + refactor log final

Files:

  • Modify: docs/decisions/adr-014-instrumentation-sentry.md

  • Modify: docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md

  • Step 1: Finalize ADR-014

Replace stub content with:

# ADR-014 — Instrumentation & Sentry Logging

**Status:** Accepted
**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

## Context

The monorepo had no distributed tracing or error capture. Production failures surfaced only via stdout / stderr from a Vercel function log. We needed:

1. End-to-end traces (browser → tRPC → use case → repo → Payload) to diagnose latency without ad-hoc timers.
2. Centralized exception capture so silent failures (especially in CMS mutations) get a permanent record with stack + context.
3. Privacy posture suitable for production: scrubbing for PII, masked replay, no opaque-vs-named user identifiers.

The Lazar Nikolov reference repo (`nextjs-clean-architecture`) demonstrates a Sentry-driven pattern with `Sentry.startSpan` and `Sentry.captureException` calls inline in use cases and repos. We needed to adapt this to:

- Three apps (web-next, cms, web-tanstack) — not one.
- Per-feature DI (ADR-008) — not a single container.
- Vendor neutrality — feature packages must not import `@sentry/*` directly.

## Decision

**1. Vendor-neutral interfaces in `core-shared/instrumentation/`.** Two interfaces (`ITracer`, `ILogger`) with three implementation pairs (`Noop*`, `Sentry*`, `Recording*`). Feature packages depend only on the interfaces.

**2. Full-depth instrumentation.** Spans nest at every layer: tRPC procedure (auto) → controller (DI-wrapped) → use case (DI-wrapped) → repository (explicit `startSpan`) → Payload (auto). Use case + controller spans applied via `withSpan` higher-order wrapper at DI binding time so factory code stays unchanged. Repositories pay explicit boilerplate per method — accepted cost for per-method visibility.

**3. Throw-site capture with double-report guard.** `Sentry.captureException` fires only at the layer that originates the error (repos catch infra; use cases catch their own throws; controllers catch parse failures; middleware does not capture). A non-enumerable `__sentryReported` flag prevents re-capture as errors bubble.

**4. Hard PII rules (R31R38).** `sendDefaultPii: false` (CI-grep enforced); replay default-masks all text/inputs/media (allowlist empty by default); `beforeSend` / `beforeSendTransaction` scrubbers strip emails/passwords/tokens/cookies/auth/IPs; `setUser` accepts only `{ id }`.

**5. Three Sentry projects, orthogonal binding.** Each app gets its own DSN. `bindAll()`'s Rule 0 (DSN → Sentry vs Noop) is independent of `USE_DEV_SEED` / `NODE_ENV` repo binding. Optional dev-mode Sentry: developer can run `pnpm dev` with `SENTRY_DSN` set to test integration locally.

**6. ESLint boundary rule (R40).** `no-restricted-imports` blocks `@sentry/*` outside `core-shared/instrumentation/sentry/**` and the apps' instrumentation/build-config files.

**7. Test-side `RecordingTracer` / `RecordingLogger`** in `core-testing/instrumentation/`. Tests inject them directly into factory functions (consistent with R27 from Plan 9 — direct-injection, not container manipulation).

## Alternatives considered

- **Direct `@sentry/nextjs` imports in features (Lazar's pattern).** Rejected — couples every feature package to a vendor SDK, violating the architecture's vendor-isolation principle.
- **Procedure-only spans (no per-use-case or per-repo spans).** Rejected — would lose the breakdown that makes a slow request diagnosable. The middle path (procedure + use case + controller, no per-repo) was rejected for the same reason at a finer granularity.
- **Capture in `defineErrorMiddleware` only.** Rejected — would noisily report every input-parse / unauthenticated error as a Sentry event, polluting the inbox.
- **Single Sentry project for all apps with environment tags.** Rejected — different alert routing, different quotas. Three projects scale better.
- **Always-Sentry in all environments.** Rejected — `pnpm test` and `pnpm dev` should not initialize a real SDK by default. Optional dev (DSN-driven) is the cleanest rule.
- **Replay flags as configurable env vars.** Rejected — the privacy posture must be the default; opt-out requires per-selector justification (R34, R51).

## Consequences

**Positive:**
- End-to-end traces in Sentry with full context.
- One captured event per error (no double-report, no noise from expected domain errors).
- Privacy-by-default replay and scrubbing.
- Vendor-swappable: replacing Sentry means writing one new adapter pair in `core-shared/instrumentation/<vendor>/`.
- Tests run against `Recording*` for assertions; `Noop*` by default.

**Negative:**
- Every public repo method gains ~6 lines of `tracer.startSpan(...)` boilerplate. Mitigated by uniform pattern; if it ever proves excessive, a `withRepoSpan` collapse helper can be added.
- `__sentryReported` flag mutates errors. Non-enumerable, so JSON / spread are unaffected; flag is checked only inside `SentryLogger`.
- Three Sentry projects to administer.
- Replay bundle peak (~250KB on errored sessions) — accepted; healthy sessions don't load replay payload.

## Related

- ADR-008 — per-feature DI containers
- ADR-011 — TDD foundation
- ADR-012 — Lazar pattern conformance
- ADR-013 — input/output unification
- Plan 10 spec (R31R55)
  • Step 2: Tick off all tasks in the refactor log + add notable surprises

Open docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md and:

  • Change every [ ] to [x] on completed tasks.

  • Populate the "Notable surprises" section with anything that came up during execution (e.g., Sentry SDK signature changes, R32 regex anchor adjustments, vite-plugin auth-token scoping, etc.).

  • Populate "Decisions deviated from spec" with any in-flight design changes.

  • Step 3: Run a final monorepo build + test

Run: pnpm build && pnpm test && pnpm lint Expected: all green.

  • Step 4: Verify CI grep gate is wired
grep -RIn --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.cjs' --include='*.js' \
    -E 'sendDefaultPii\s*:\s*true' packages/ apps/ || echo "OK — R31 grep gate clean"

Expected: OK — R31 grep gate clean.

  • Step 5: Commit
git add docs/decisions/adr-014-instrumentation-sentry.md \
        docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md
git commit -m "docs(plan-10): finalize ADR-014 + tick refactor log"

Final summary (after Task 33)

After Plan 10 lands:

  • 5 features each have full-depth instrumentation (procedure auto-span + DI-wrapped controller/use-case spans + explicit repo spans).
  • 3 apps initialize Sentry (with R31R38 PII rules) only when their DSN env is set; otherwise NoopTracer/NoopLogger.
  • Every repo contract suite asserts span shape per method (R50).
  • ESLint (R40) blocks @sentry/* outside the allowlisted paths.
  • CI grep (R31) blocks sendDefaultPii: true from ever landing.
  • Tests run against Recording* mocks; real Sentry SDK never initializes in vitest (R49).
  • Docs (CLAUDE.md, AGENTS.md, vertical-feature-spec.md, tdd-workflow.md, testing-strategy.md, dependency-flow.md, core-shared/AGENTS.md) explain the conventions.
  • HTML explainers (data-flow-explainer §06, di-explainer instrumentation section) show the wiring visually.
  • ADR-014 records the design decisions.

Branch is ready for review and merge.