# Plan 10 — Instrumentation + Sentry Logging

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add distributed tracing and exception capture to the monorepo with vendor-agnostic interfaces in `core-shared`, full-depth spans (procedure → controller → use-case → repository), throw-site error capture with double-report guard, and hard-coded PII rules across three apps (web-next, cms, web-tanstack).

**Architecture:** Two interfaces (`ITracer`, `ILogger`) in `core-shared/instrumentation/` with `Noop`, `Sentry`, and (in `core-testing`) `Recording` implementations. Use-case + controller spans applied via a `withSpan` higher-order wrapper at DI binding time; repository methods emit explicit `tracer.startSpan(...)` calls. Per-app DSNs init Sentry only when env is set; Noop is the default everywhere else (orthogonal to USE_DEV_SEED / NODE_ENV).

**Tech Stack:** `@sentry/nextjs` (web-next, cms), `@sentry/node` + `@sentry/vite-plugin` (web-tanstack), inversify (existing), zod (existing), vitest (existing), `eslint-plugin-no-restricted-imports` (boundary rule).

**Source spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31–R55).

---

## File structure overview

### Created (core-shared)

```
packages/core-shared/src/instrumentation/
├── index.ts                                — public re-exports
├── tracer.interface.ts                     — ITracer, ISpan, SpanOpts, AttributeValue
├── logger.interface.ts                     — ILogger, CaptureContext, Breadcrumb
├── noop-tracer.ts                          — pass-through ITracer
├── noop-tracer.test.ts
├── noop-logger.ts                          — pass-through ILogger
├── noop-logger.test.ts
├── with-span.ts                            — higher-order span wrapper
├── with-span.test.ts
├── symbols.ts                              — TRACER, LOGGER inversify symbols
├── sentry/
│   ├── sentry-tracer.ts
│   ├── sentry-tracer.test.ts
│   ├── sentry-logger.ts
│   ├── sentry-logger.test.ts
│   ├── scrub.ts                            — beforeSend + beforeSendTransaction
│   ├── scrub.test.ts
│   ├── pii-fields.ts                       — regex constants
│   ├── init-server.ts
│   ├── init-server.test.ts
│   ├── init-client.ts
│   └── init-client.test.ts
└── di/
    ├── bind-noop-instrumentation.ts
    ├── bind-noop-instrumentation.test.ts
    ├── bind-sentry-instrumentation.ts
    └── bind-sentry-instrumentation.test.ts
```

### Created (core-testing)

```
packages/core-testing/src/instrumentation/
├── index.ts
├── recording-tracer.ts                     — captures startSpan calls
├── recording-tracer.test.ts
├── recording-logger.ts                     — captures captureException calls
└── recording-logger.test.ts
```

### Modified (core-testing)

- `packages/core-testing/src/index.ts` — re-export from `./instrumentation/index.js`
- `packages/core-testing/src/setup/vitest.setup.ts` — bind Noop instrumentation by default

### Modified (every feature: blog, auth, marketing-pages, navigation, media)

- `packages/<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.ts` — `bindAll()` gains Rule 0 (DSN → Sentry vs Noop), threads tracer/logger to every feature binder
- `apps/web-next/instrumentation.ts` — NEW
- `apps/web-next/instrumentation-client.ts` — NEW
- `apps/web-next/next.config.mjs` — wrap with `withSentryConfig`
- `apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts` — NEW (R38)
- `apps/cms/instrumentation.ts` — NEW
- `apps/cms/next.config.mjs` — wrap with `withSentryConfig`
- `apps/cms/src/__tests__/sentry-pii-scrubber.test.ts` — NEW (R38)
- `apps/web-tanstack/src/instrumentation.ts` — NEW
- `apps/web-tanstack/src/instrumentation-client.ts` — NEW
- `apps/web-tanstack/vite.config.ts` — add `@sentry/vite-plugin`
- `apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts` — NEW (R38)

### Modified (config + tooling)

- `turbo.json` — `globalEnv` adds 8 new vars (per spec §4.7)
- `packages/core-eslint/base.js` — add `no-restricted-imports` for `@sentry/*` (R40)
- `.github/workflows/ci.yml` (or equivalent) — grep step for `sendDefaultPii: true` (R31)

### Modified (docs + HTML)

- `docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md` — NEW (R54)
- `docs/decisions/adr-014-instrumentation-sentry.md` — NEW (R55)
- `CLAUDE.md` — instrumentation conventions
- `AGENTS.md` — TRACER/LOGGER symbols, repo span rule, capture rule
- `docs/architecture/vertical-feature-spec.md` — add §10 instrumentation
- `docs/guides/tdd-workflow.md` — RecordingTracer/Logger usage
- `docs/guides/testing-strategy.md` — span/capture assertion patterns
- `docs/architecture/dependency-flow.md` — TRACER/LOGGER dataflow
- `docs/architecture/data-flow-explainer.html` — new §07 "Tracing & error capture"
- `docs/architecture/di-explainer.html` — instrumentation symbols + binders
- `packages/core-shared/AGENTS.md` — instrumentation/ subfolder conventions

---

## Task index

- **Phase A — Foundation:** Tasks 1–6 (scaffold, interfaces, Noops, withSpan, symbols)
- **Phase B — Sentry adapters:** Tasks 7–11 (SentryTracer, SentryLogger, scrubbers, init helpers)
- **Phase C — DI binders:** Tasks 12–14 (bindNoop, bindSentry, bindAll dispatcher)
- **Phase D — Test infra:** Tasks 15–17 (RecordingTracer, RecordingLogger, vitest setup)
- **Phase E — Per-feature wiring:** Tasks 18–22 (blog pilot + auth + marketing-pages + navigation + media)
- **Phase F — Contract + factory upgrades:** Tasks 23–24 (defineContractSuite expectSpan, contract suite updates)
- **Phase G — App integration:** Tasks 25–27 (web-next, cms, web-tanstack)
- **Phase H — Boundary + config:** Tasks 28–29 (ESLint rule + CI grep + turbo.json)
- **Phase I — Docs + HTML:** Tasks 30–33 (refactor-log/ADR final, docs pass, HTML updates)

---

(Tasks below — each TDD'd, single-commit, code-complete.)

---

## Phase A — Foundation

### Task 1: Scaffold refactor log + ADR-014 stub

**Files:**
- Create: `docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md`
- Create: `docs/decisions/adr-014-instrumentation-sentry.md`

- [ ] **Step 1: Write the refactor log**

```markdown
# Refactor Log — Instrumentation + Sentry Logging (Plan 10)

**Date:** 2026-05-06
**Spec:** docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md
**Plan:** docs/superpowers/plans/2026-05-06-plan-10-instrumentation-sentry.md
**Branch:** feature/instrumentation-sentry

## Tasks

- [ ] Task 1 — Scaffold refactor log + ADR-014 stub
- [ ] Task 2 — Tracer interface + ISpan + AttributeValue + SpanOpts
- [ ] Task 3 — NoopTracer
- [ ] Task 4 — Logger interface + NoopLogger + Breadcrumb + CaptureContext
- [ ] Task 5 — withSpan helper
- [ ] Task 6 — Symbols + index barrel
- [ ] Task 7 — SentryTracer adapter
- [ ] Task 8 — SentryLogger adapter (with double-report guard)
- [ ] Task 9 — pii-fields constants + scrub.beforeSend / scrub.beforeSendTransaction
- [ ] Task 10 — init-server helper
- [ ] Task 11 — init-client helper (browser-only)
- [ ] Task 12 — bindNoopInstrumentation + bindSentryInstrumentation
- [ ] Task 13 — apps/web-next bindAll() Rule 0 dispatcher
- [ ] Task 14 — Tests for bindAll() orthogonality (R47)
- [ ] Task 15 — RecordingTracer in core-testing
- [ ] Task 16 — RecordingLogger in core-testing
- [ ] Task 17 — vitest.setup.ts binds Noop by default
- [ ] Task 18 — Blog feature wiring (pilot)
- [ ] Task 19 — Auth feature wiring
- [ ] Task 20 — Marketing-pages feature wiring
- [ ] Task 21 — Navigation feature wiring
- [ ] Task 22 — Media feature wiring
- [ ] Task 23 — defineContractSuite expectSpan helper
- [ ] Task 24 — Update repo contract suites to assert span shape
- [ ] Task 25 — apps/web-next instrumentation files + scrubber test
- [ ] Task 26 — apps/cms instrumentation files + scrubber test
- [ ] Task 27 — apps/web-tanstack instrumentation files + scrubber test
- [ ] Task 28 — ESLint boundary rule (R40) + CI grep gate (R31)
- [ ] Task 29 — turbo.json globalEnv updates
- [ ] Task 30 — Doc updates (CLAUDE.md, AGENTS.md, vertical-feature-spec.md)
- [ ] Task 31 — Doc updates (tdd-workflow.md, testing-strategy.md, dependency-flow.md, core-shared/AGENTS.md)
- [ ] Task 32 — HTML updates (data-flow-explainer §07, di-explainer additions)
- [ ] Task 33 — ADR-014 final + refactor log final

## Decisions deviated from spec

(populate as work progresses)

## Notable surprises

(populate as work progresses)
```

- [ ] **Step 2: Write the ADR-014 stub**

```markdown
# ADR-014 — Instrumentation & Sentry Logging

**Status:** Proposed (will be Accepted on Plan 10 completion)
**Date:** 2026-05-06
**Spec:** docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md

## Context

(stub — finalized in Task 33)

## Decision

(stub — finalized in Task 33)

## Consequences

(stub — finalized in Task 33)
```

- [ ] **Step 3: Commit**

```bash
git add docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md \
        docs/decisions/adr-014-instrumentation-sentry.md
git commit -m "chore(plan-10): scaffold refactor log + ADR-014 stub"
```

---

### Task 2: Tracer interface + ISpan + types

**Files:**
- Create: `packages/core-shared/src/instrumentation/tracer.interface.ts`
- (Tests come in Task 3 — interface alone has nothing to test.)

- [ ] **Step 1: Write the file**

```ts
// packages/core-shared/src/instrumentation/tracer.interface.ts

export type AttributeValue = string | number | boolean | null;

export type SpanOpts = {
  name: string;
  op?: "use-case" | "controller" | "repository" | "service" | string;
  attributes?: Record<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**

```bash
git add packages/core-shared/src/instrumentation/tracer.interface.ts
git commit -m "feat(core-shared): add ITracer/ISpan interfaces"
```

---

### Task 3: NoopTracer

**Files:**
- Create: `packages/core-shared/src/instrumentation/noop-tracer.ts`
- Create: `packages/core-shared/src/instrumentation/noop-tracer.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-shared/src/instrumentation/noop-tracer.test.ts
import { describe, it, expect, vi } from "vitest";
import { NoopTracer } from "@/instrumentation/noop-tracer";
import type { ISpan } from "@/instrumentation/tracer.interface";

describe("NoopTracer", () => {
  it("startSpan returns the function result", async () => {
    const tracer = new NoopTracer();
    const result = await tracer.startSpan({ name: "test.op" }, async () => 42);
    expect(result).toBe(42);
  });

  it("startSpan passes a no-op ISpan to the function", async () => {
    const tracer = new NoopTracer();
    let received: ISpan | undefined;
    await tracer.startSpan({ name: "test.op" }, async (span) => {
      received = span;
      return undefined;
    });
    expect(received).toBeDefined();
    // setAttribute and setStatus must be callable without throwing
    expect(() => received!.setAttribute("k", "v")).not.toThrow();
    expect(() => received!.setStatus("ok")).not.toThrow();
    expect(() => received!.setStatus("error", "msg")).not.toThrow();
  });

  it("propagates exceptions from the wrapped function", async () => {
    const tracer = new NoopTracer();
    const err = new Error("boom");
    await expect(
      tracer.startSpan({ name: "test.op" }, async () => {
        throw err;
      }),
    ).rejects.toBe(err);
  });

  it("does not invoke external services", async () => {
    const tracer = new NoopTracer();
    const fn = vi.fn(async () => "ok");
    await tracer.startSpan({ name: "test.op" }, fn);
    expect(fn).toHaveBeenCalledTimes(1);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @repo/core-shared test noop-tracer`
Expected: FAIL — `NoopTracer` not found.

- [ ] **Step 3: Implement NoopTracer**

```ts
// packages/core-shared/src/instrumentation/noop-tracer.ts
import type { ITracer, ISpan, SpanOpts } from "./tracer.interface";

const NOOP_SPAN: ISpan = {
  setAttribute: () => {},
  setStatus: () => {},
};

export class NoopTracer implements ITracer {
  async startSpan<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**

```bash
git add packages/core-shared/src/instrumentation/noop-tracer.ts \
        packages/core-shared/src/instrumentation/noop-tracer.test.ts
git commit -m "feat(core-shared): add NoopTracer"
```

---

### Task 4: Logger interface + NoopLogger

**Files:**
- Create: `packages/core-shared/src/instrumentation/logger.interface.ts`
- Create: `packages/core-shared/src/instrumentation/noop-logger.ts`
- Create: `packages/core-shared/src/instrumentation/noop-logger.test.ts`

- [ ] **Step 1: Write the interface**

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

export type Breadcrumb = {
  category: string;
  message: string;
  level?: "info" | "warning" | "error";
  data?: Record<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**

```ts
// packages/core-shared/src/instrumentation/noop-logger.test.ts
import { describe, it, expect } from "vitest";
import { NoopLogger } from "@/instrumentation/noop-logger";

describe("NoopLogger", () => {
  it("captureException is callable with err and ctx", () => {
    const logger = new NoopLogger();
    expect(() => logger.captureException(new Error("x"))).not.toThrow();
    expect(() =>
      logger.captureException(new Error("x"), { tags: { feature: "blog" } }),
    ).not.toThrow();
  });

  it("captureMessage is callable", () => {
    const logger = new NoopLogger();
    expect(() => logger.captureMessage("hello")).not.toThrow();
    expect(() => logger.captureMessage("hello", "warning")).not.toThrow();
    expect(() =>
      logger.captureMessage("hello", "error", { extras: { foo: 1 } }),
    ).not.toThrow();
  });

  it("addBreadcrumb is callable", () => {
    const logger = new NoopLogger();
    expect(() =>
      logger.addBreadcrumb({ category: "test", message: "x" }),
    ).not.toThrow();
  });

  it("setUser accepts opaque id and null", () => {
    const logger = new NoopLogger();
    expect(() => logger.setUser({ id: "u1" })).not.toThrow();
    expect(() => logger.setUser(null)).not.toThrow();
  });
});
```

- [ ] **Step 3: Run test to verify it fails**

Run: `pnpm --filter @repo/core-shared test noop-logger`
Expected: FAIL — `NoopLogger` not found.

- [ ] **Step 4: Implement NoopLogger**

```ts
// packages/core-shared/src/instrumentation/noop-logger.ts
import type { ILogger, Breadcrumb, CaptureContext } from "./logger.interface";

export class NoopLogger implements ILogger {
  captureException(_err: unknown, _ctx?: CaptureContext): void {}
  captureMessage(
    _msg: string,
    _level?: "info" | "warning" | "error",
    _ctx?: CaptureContext,
  ): void {}
  addBreadcrumb(_b: Breadcrumb): void {}
  setUser(_user: { id: string } | null): void {}
}
```

- [ ] **Step 5: Run test to verify it passes**

Run: `pnpm --filter @repo/core-shared test noop-logger`
Expected: PASS — 4 tests.

- [ ] **Step 6: Commit**

```bash
git add packages/core-shared/src/instrumentation/logger.interface.ts \
        packages/core-shared/src/instrumentation/noop-logger.ts \
        packages/core-shared/src/instrumentation/noop-logger.test.ts
git commit -m "feat(core-shared): add ILogger interface + NoopLogger"
```

---

### Task 5: `withSpan` helper

**Files:**
- Create: `packages/core-shared/src/instrumentation/with-span.ts`
- Create: `packages/core-shared/src/instrumentation/with-span.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-shared/src/instrumentation/with-span.test.ts
import { describe, it, expect, vi } from "vitest";
import { withSpan } from "@/instrumentation/with-span";
import type { ITracer, ISpan, SpanOpts } from "@/instrumentation/tracer.interface";

function makeRecordingTracer() {
  const calls: SpanOpts[] = [];
  const tracer: ITracer = {
    startSpan: vi.fn(async (opts, fn) => {
      calls.push(opts);
      const span: ISpan = { setAttribute: () => {}, setStatus: () => {} };
      return fn(span);
    }),
  };
  return { tracer, calls };
}

describe("withSpan", () => {
  it("wraps fn with a span using static opts", async () => {
    const { tracer, calls } = makeRecordingTracer();
    const fn = async (a: number, b: number) => a + b;
    const wrapped = withSpan(tracer, { name: "test.add", op: "use-case" }, fn);
    const result = await wrapped(2, 3);
    expect(result).toBe(5);
    expect(calls).toHaveLength(1);
    expect(calls[0]).toEqual({ name: "test.add", op: "use-case" });
  });

  it("wraps fn with span opts derived from args (function form)", async () => {
    const { tracer, calls } = makeRecordingTracer();
    const fn = async (id: string) => `result-${id}`;
    const wrapped = withSpan(
      tracer,
      ([id]) => ({ name: "test.byId", op: "repository", attributes: { id } }),
      fn,
    );
    const result = await wrapped("abc");
    expect(result).toBe("result-abc");
    expect(calls).toHaveLength(1);
    expect(calls[0]).toEqual({
      name: "test.byId",
      op: "repository",
      attributes: { id: "abc" },
    });
  });

  it("propagates errors thrown by fn", async () => {
    const { tracer } = makeRecordingTracer();
    const wrapped = withSpan(tracer, { name: "test.err" }, async () => {
      throw new Error("boom");
    });
    await expect(wrapped()).rejects.toThrow("boom");
  });

  it("preserves identity across multiple invocations (closure stable)", async () => {
    const { tracer, calls } = makeRecordingTracer();
    const wrapped = withSpan(tracer, { name: "test.same" }, async (n: number) => n);
    await wrapped(1);
    await wrapped(2);
    expect(calls).toHaveLength(2);
    expect(calls.every((c) => c.name === "test.same")).toBe(true);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @repo/core-shared test with-span`
Expected: FAIL — `withSpan` not found.

- [ ] **Step 3: Implement withSpan**

```ts
// packages/core-shared/src/instrumentation/with-span.ts
import type { ITracer, SpanOpts } from "./tracer.interface";

export function withSpan<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**

```bash
git add packages/core-shared/src/instrumentation/with-span.ts \
        packages/core-shared/src/instrumentation/with-span.test.ts
git commit -m "feat(core-shared): add withSpan higher-order helper"
```

---

### Task 6: Symbols + index barrel

**Files:**
- Create: `packages/core-shared/src/instrumentation/symbols.ts`
- Create: `packages/core-shared/src/instrumentation/index.ts`
- Modify: `packages/core-shared/src/index.ts` (re-export instrumentation)
- Modify: `packages/core-shared/package.json` (add `./instrumentation` subpath if not implicit)

- [ ] **Step 1: Write the symbols file**

```ts
// packages/core-shared/src/instrumentation/symbols.ts
export const INSTRUMENTATION_SYMBOLS = {
  TRACER: Symbol.for("core-shared.TRACER"),
  LOGGER: Symbol.for("core-shared.LOGGER"),
} as const;
```

- [ ] **Step 2: Write the index barrel**

```ts
// packages/core-shared/src/instrumentation/index.ts
export type {
  ITracer,
  ISpan,
  SpanOpts,
  AttributeValue,
} from "./tracer.interface";
export type {
  ILogger,
  Breadcrumb,
  CaptureContext,
} from "./logger.interface";
export { NoopTracer } from "./noop-tracer";
export { NoopLogger } from "./noop-logger";
export { withSpan } from "./with-span";
export { INSTRUMENTATION_SYMBOLS } from "./symbols";
```

- [ ] **Step 3: Re-export from package root**

```ts
// packages/core-shared/src/index.ts (append at bottom; preserve existing exports)
export * from "./instrumentation/index";
```

- [ ] **Step 4: Add subpath export in package.json**

Open `packages/core-shared/package.json` and ensure the `exports` field includes:

```json
{
  "exports": {
    ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
    "./instrumentation": {
      "import": "./dist/instrumentation/index.js",
      "types": "./dist/instrumentation/index.d.ts"
    },
    "./trpc/define-error-middleware": {
      "import": "./dist/trpc/define-error-middleware.js",
      "types": "./dist/trpc/define-error-middleware.d.ts"
    }
  }
}
```

(Preserve any existing entries — the example shows the *additions*. If the current file uses different export structure, integrate accordingly.)

- [ ] **Step 5: Build to verify**

Run: `pnpm --filter @repo/core-shared build`
Expected: build succeeds.

- [ ] **Step 6: Verify subpath import works**

Create a one-off check:

```bash
cat <<'EOF' > /tmp/check-instrumentation.ts
import { NoopTracer, NoopLogger, withSpan, INSTRUMENTATION_SYMBOLS } from "@repo/core-shared/instrumentation";
console.log(typeof NoopTracer, typeof NoopLogger, typeof withSpan, INSTRUMENTATION_SYMBOLS.TRACER);
EOF
cd packages/core-shared && pnpm tsc --noEmit /tmp/check-instrumentation.ts
```

Expected: no errors.

(Delete `/tmp/check-instrumentation.ts` after.)

- [ ] **Step 7: Commit**

```bash
git add packages/core-shared/src/instrumentation/symbols.ts \
        packages/core-shared/src/instrumentation/index.ts \
        packages/core-shared/src/index.ts \
        packages/core-shared/package.json
git commit -m "feat(core-shared): symbols + barrel for instrumentation subpath"
```

---

## Phase B — Sentry adapters

> **Prerequisite:** Add `@sentry/nextjs` to `packages/core-shared/package.json` dependencies before starting Task 7. The adapter files import from it.

```bash
pnpm --filter @repo/core-shared add @sentry/nextjs
```

### Task 7: SentryTracer adapter

**Files:**
- Create: `packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts`
- Create: `packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@sentry/nextjs", () => ({
  startSpan: vi.fn((_opts, fn) => fn({ setAttribute: vi.fn(), setStatus: vi.fn() })),
}));

import * as Sentry from "@sentry/nextjs";
import { SentryTracer } from "@/instrumentation/sentry/sentry-tracer";

describe("SentryTracer", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("delegates startSpan to @sentry/nextjs.startSpan", async () => {
    const tracer = new SentryTracer();
    const result = await tracer.startSpan(
      { name: "blog.getArticles", op: "use-case" },
      async () => "value",
    );
    expect(result).toBe("value");
    expect(Sentry.startSpan).toHaveBeenCalledTimes(1);
    expect((Sentry.startSpan as any).mock.calls[0][0]).toMatchObject({
      name: "blog.getArticles",
      op: "use-case",
    });
  });

  it("forwards attributes to Sentry", async () => {
    const tracer = new SentryTracer();
    await tracer.startSpan(
      { name: "articles.findAll", op: "repository", attributes: { collection: "articles", limit: 10 } },
      async () => undefined,
    );
    expect((Sentry.startSpan as any).mock.calls[0][0].attributes).toEqual({
      collection: "articles",
      limit: 10,
    });
  });

  it("propagates errors from the wrapped function", async () => {
    const tracer = new SentryTracer();
    await expect(
      tracer.startSpan({ name: "x" }, async () => {
        throw new Error("boom");
      }),
    ).rejects.toThrow("boom");
  });

  it("ISpan adapter forwards setAttribute and setStatus to Sentry's span", async () => {
    const sentrySpan = { setAttribute: vi.fn(), setStatus: vi.fn() };
    (Sentry.startSpan as any).mockImplementationOnce((_opts: unknown, fn: any) => fn(sentrySpan));
    const tracer = new SentryTracer();
    await tracer.startSpan({ name: "x" }, async (span) => {
      span.setAttribute("k", "v");
      span.setStatus("error", "msg");
      return undefined;
    });
    expect(sentrySpan.setAttribute).toHaveBeenCalledWith("k", "v");
    // Sentry uses status code 2 for error, 1 for ok — but our adapter passes through the string
    expect(sentrySpan.setStatus).toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @repo/core-shared test sentry-tracer`
Expected: FAIL — `SentryTracer` not found.

- [ ] **Step 3: Implement SentryTracer**

```ts
// packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts
import * as Sentry from "@sentry/nextjs";
import type {
  ITracer,
  ISpan,
  SpanOpts,
} from "../tracer.interface";

export class SentryTracer implements ITracer {
  async startSpan<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**

```bash
git add packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts \
        packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts \
        packages/core-shared/package.json packages/core-shared/pnpm-lock.yaml
# (pnpm-lock if separate; otherwise root lockfile)
git commit -m "feat(core-shared): SentryTracer adapter"
```

---

### Task 8: SentryLogger adapter (with double-report guard)

**Files:**
- Create: `packages/core-shared/src/instrumentation/sentry/sentry-logger.ts`
- Create: `packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@sentry/nextjs", () => ({
  captureException: vi.fn(),
  captureMessage: vi.fn(),
  addBreadcrumb: vi.fn(),
  setUser: vi.fn(),
}));

import * as Sentry from "@sentry/nextjs";
import { SentryLogger } from "@/instrumentation/sentry/sentry-logger";

describe("SentryLogger", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("captureException forwards to Sentry on first call", () => {
    const logger = new SentryLogger();
    const err = new Error("boom");
    logger.captureException(err, { tags: { feature: "blog" } });
    expect(Sentry.captureException).toHaveBeenCalledTimes(1);
    expect((Sentry.captureException as any).mock.calls[0][0]).toBe(err);
  });

  it("captureException is a no-op when err already marked __sentryReported", () => {
    const logger = new SentryLogger();
    const err = new Error("already-reported");
    Object.defineProperty(err, "__sentryReported", { value: true });
    logger.captureException(err);
    expect(Sentry.captureException).not.toHaveBeenCalled();
  });

  it("captureException marks err as __sentryReported after sending", () => {
    const logger = new SentryLogger();
    const err = new Error("once");
    logger.captureException(err);
    expect((err as unknown as { __sentryReported: boolean }).__sentryReported).toBe(true);
    // Second call: no-op
    logger.captureException(err);
    expect(Sentry.captureException).toHaveBeenCalledTimes(1);
  });

  it("__sentryReported is non-enumerable", () => {
    const logger = new SentryLogger();
    const err = new Error("x");
    logger.captureException(err);
    expect(Object.keys(err)).not.toContain("__sentryReported");
    expect(JSON.stringify(err)).not.toContain("__sentryReported");
  });

  it("captureMessage forwards to Sentry", () => {
    const logger = new SentryLogger();
    logger.captureMessage("hello", "warning", { tags: { foo: "bar" } });
    expect(Sentry.captureMessage).toHaveBeenCalledWith("hello", expect.objectContaining({ level: "warning" }));
  });

  it("addBreadcrumb forwards to Sentry", () => {
    const logger = new SentryLogger();
    logger.addBreadcrumb({ category: "test", message: "x", data: { k: "v" } });
    expect(Sentry.addBreadcrumb).toHaveBeenCalledTimes(1);
  });

  it("setUser strips non-id keys and warns in dev", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    const logger = new SentryLogger();
    logger.setUser({ id: "u1", email: "a@b.c", username: "alice" } as any);
    expect(Sentry.setUser).toHaveBeenCalledWith({ id: "u1" });
    expect(warn).toHaveBeenCalled();
    warn.mockRestore();
  });

  it("setUser passes null through", () => {
    const logger = new SentryLogger();
    logger.setUser(null);
    expect(Sentry.setUser).toHaveBeenCalledWith(null);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @repo/core-shared test sentry-logger`
Expected: FAIL — `SentryLogger` not found.

- [ ] **Step 3: Implement SentryLogger**

```ts
// packages/core-shared/src/instrumentation/sentry/sentry-logger.ts
import * as Sentry from "@sentry/nextjs";
import type {
  ILogger,
  Breadcrumb,
  CaptureContext,
} from "../logger.interface";

const REPORTED = "__sentryReported" as const;

function isReported(err: unknown): boolean {
  return (
    err !== null &&
    typeof err === "object" &&
    Boolean((err as Record<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**

```bash
git add packages/core-shared/src/instrumentation/sentry/sentry-logger.ts \
        packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts
git commit -m "feat(core-shared): SentryLogger with double-report guard + R36 user-context strip"
```

---

### Task 9: PII fields + scrubbers (R32, R33)

**Files:**
- Create: `packages/core-shared/src/instrumentation/sentry/pii-fields.ts`
- Create: `packages/core-shared/src/instrumentation/sentry/scrub.ts`
- Create: `packages/core-shared/src/instrumentation/sentry/scrub.test.ts`

- [ ] **Step 1: Write the constants**

```ts
// packages/core-shared/src/instrumentation/sentry/pii-fields.ts

// R32 — substring match on event keys (case-insensitive)
export const PII_KEY_SUBSTRINGS = [
  "email",
  "password",
  "token",
  "cookie",
  "authorization",
  "set-cookie",
  "x-api-key",
  "apikey",
  "api_key",
  "secret",
] as const;

// R33 — substring match on URL query-param keys (case-insensitive)
export const PII_QUERY_PARAM_SUBSTRINGS = [
  "token",
  "email",
  "password",
  "key",
  "sig",
  "signature",
  "access_token",
  "accesstoken",
  "secret",
] as const;

export const REDACTED_VALUE = "[redacted]" as const;
export const REDACTED_IP = "[redacted-ip]" as const;

// IPv4: simple dotted-quad; IPv6: any colon-separated hex with at least one ::
export const IPV4_REGEX = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;
export const IPV6_REGEX = /\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}/g;

export function keyContainsPii(key: string): boolean {
  const lower = key.toLowerCase();
  return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}

export function queryParamContainsPii(key: string): boolean {
  const lower = key.toLowerCase();
  return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s));
}
```

- [ ] **Step 2: Write the failing test**

```ts
// packages/core-shared/src/instrumentation/sentry/scrub.test.ts
import { describe, it, expect } from "vitest";
import { beforeSend, beforeSendTransaction } from "@/instrumentation/sentry/scrub";

describe("beforeSend", () => {
  it("redacts top-level keys whose names contain PII substrings", () => {
    const event = {
      extra: { email: "a@b.c", username: "alice" },
      contexts: { custom: { password: "p", note: "ok" } },
    } as any;
    const result = beforeSend(event, {} as any) as any;
    expect(result.extra.email).toBe("[redacted]");
    expect(result.extra.username).toBe("alice");
    expect(result.contexts.custom.password).toBe("[redacted]");
    expect(result.contexts.custom.note).toBe("ok");
  });

  it("redacts derived key names (substring match): userEmail, accessToken, apiKey", () => {
    const event = {
      extra: { userEmail: "a@b.c", accessToken: "t", apiKey: "k", id: "u1" },
    } as any;
    const result = beforeSend(event, {} as any) as any;
    expect(result.extra.userEmail).toBe("[redacted]");
    expect(result.extra.accessToken).toBe("[redacted]");
    expect(result.extra.apiKey).toBe("[redacted]");
    expect(result.extra.id).toBe("u1");
  });

  it("redacts headers map keys case-insensitively", () => {
    const event = {
      request: {
        headers: { Authorization: "Bearer x", "Set-Cookie": "session=abc", "User-Agent": "ua" },
      },
    } as any;
    const result = beforeSend(event, {} as any) as any;
    expect(result.request.headers.Authorization).toBe("[redacted]");
    expect(result.request.headers["Set-Cookie"]).toBe("[redacted]");
    expect(result.request.headers["User-Agent"]).toBe("ua");
  });

  it("redacts IPv4 addresses found in string values", () => {
    const event = { extra: { note: "Connection from 192.168.1.10 failed" } } as any;
    const result = beforeSend(event, {} as any) as any;
    expect(result.extra.note).toBe("Connection from [redacted-ip] failed");
  });

  it("redacts IPv6 addresses found in string values", () => {
    const event = { extra: { note: "Tunnel to fe80::1ff:fe23:4567:890a established" } } as any;
    const result = beforeSend(event, {} as any) as any;
    expect(result.extra.note).toContain("[redacted-ip]");
  });

  it("does not crash on null/undefined branches", () => {
    expect(beforeSend({ extra: null } as any, {} as any)).toBeTruthy();
    expect(beforeSend({} as any, {} as any)).toBeTruthy();
  });

  it("returns the event (not null) — keeps Sentry transport flowing", () => {
    expect(beforeSend({ extra: { ok: true } } as any, {} as any)).toBeTruthy();
  });
});

describe("beforeSendTransaction", () => {
  it("strips PII query params from request.url", () => {
    const event = {
      request: { url: "https://app/api/foo?token=secret&user=alice&email=a@b.c" },
    } as any;
    const result = beforeSendTransaction(event, {} as any) as any;
    expect(result.request.url).toContain("token=%5Bredacted%5D");
    expect(result.request.url).toContain("email=%5Bredacted%5D");
    expect(result.request.url).toContain("user=alice");
  });

  it("strips PII query params from event.transaction", () => {
    const event = { transaction: "/foo?token=x&id=y" } as any;
    const result = beforeSendTransaction(event, {} as any) as any;
    expect(result.transaction).toContain("token=%5Bredacted%5D");
    expect(result.transaction).toContain("id=y");
  });

  it("matches derived param names (accessToken, ApiSecret)", () => {
    const event = { request: { url: "https://x/y?accessToken=t&ApiSecret=z&safe=1" } } as any;
    const result = beforeSendTransaction(event, {} as any) as any;
    expect(result.request.url).toContain("accessToken=%5Bredacted%5D");
    expect(result.request.url).toContain("ApiSecret=%5Bredacted%5D");
    expect(result.request.url).toContain("safe=1");
  });

  it("returns the event when no URL present", () => {
    expect(beforeSendTransaction({} as any, {} as any)).toBeTruthy();
  });
});
```

- [ ] **Step 3: Run test to verify it fails**

Run: `pnpm --filter @repo/core-shared test scrub`
Expected: FAIL — `beforeSend`/`beforeSendTransaction` not found.

- [ ] **Step 4: Implement scrub**

```ts
// packages/core-shared/src/instrumentation/sentry/scrub.ts
import type { ErrorEvent, EventHint, TransactionEvent } from "@sentry/nextjs";
import {
  IPV4_REGEX,
  IPV6_REGEX,
  REDACTED_IP,
  REDACTED_VALUE,
  keyContainsPii,
  queryParamContainsPii,
} from "./pii-fields";

function redactString(s: string): string {
  return s.replace(IPV4_REGEX, REDACTED_IP).replace(IPV6_REGEX, REDACTED_IP);
}

function deepScrub(value: unknown, parentKey = ""): unknown {
  if (value === null || value === undefined) return value;
  if (typeof value === "string") {
    return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value);
  }
  if (typeof value === "number" || typeof value === "boolean") {
    return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
  }
  if (Array.isArray(value)) {
    return value.map((v) => deepScrub(v, parentKey));
  }
  if (typeof value === "object") {
    const out: Record<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**

```bash
git add packages/core-shared/src/instrumentation/sentry/pii-fields.ts \
        packages/core-shared/src/instrumentation/sentry/scrub.ts \
        packages/core-shared/src/instrumentation/sentry/scrub.test.ts
git commit -m "feat(core-shared): PII scrubbers — beforeSend (R32) + beforeSendTransaction (R33)"
```

---

### Task 10: `init-server` helper

**Files:**
- Create: `packages/core-shared/src/instrumentation/sentry/init-server.ts`
- Create: `packages/core-shared/src/instrumentation/sentry/init-server.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-shared/src/instrumentation/sentry/init-server.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@sentry/nextjs", () => ({
  init: vi.fn(),
  replayIntegration: vi.fn(() => ({ name: "Replay" })),
}));

import * as Sentry from "@sentry/nextjs";
import { initSentryServer } from "@/instrumentation/sentry/init-server";

describe("initSentryServer", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("calls Sentry.init with sendDefaultPii: false (R31)", () => {
    initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
    expect(Sentry.init).toHaveBeenCalledTimes(1);
    const call = (Sentry.init as any).mock.calls[0][0];
    expect(call.sendDefaultPii).toBe(false);
  });

  it("passes the configured DSN", () => {
    initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
    const call = (Sentry.init as any).mock.calls[0][0];
    expect(call.dsn).toBe("https://x@y/1");
  });

  it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
    initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
    const call = (Sentry.init as any).mock.calls[0][0];
    expect(typeof call.beforeSend).toBe("function");
    expect(typeof call.beforeSendTransaction).toBe("function");
  });

  it("uses SENTRY_TRACES_SAMPLE_RATE env when set", () => {
    const prev = process.env.SENTRY_TRACES_SAMPLE_RATE;
    process.env.SENTRY_TRACES_SAMPLE_RATE = "0.25";
    initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
    const call = (Sentry.init as any).mock.calls[0][0];
    expect(call.tracesSampleRate).toBe(0.25);
    if (prev === undefined) delete process.env.SENTRY_TRACES_SAMPLE_RATE;
    else process.env.SENTRY_TRACES_SAMPLE_RATE = prev;
  });

  it("defaults tracesSampleRate to 1.0 in dev, 0.1 in production", () => {
    const prevEnv = process.env.NODE_ENV;
    const prevRate = process.env.SENTRY_TRACES_SAMPLE_RATE;
    delete process.env.SENTRY_TRACES_SAMPLE_RATE;

    process.env.NODE_ENV = "development";
    initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
    expect((Sentry.init as any).mock.calls[0][0].tracesSampleRate).toBe(1.0);

    (Sentry.init as any).mockClear();
    process.env.NODE_ENV = "production";
    initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
    expect((Sentry.init as any).mock.calls[0][0].tracesSampleRate).toBe(0.1);

    if (prevEnv === undefined) delete process.env.NODE_ENV;
    else process.env.NODE_ENV = prevEnv;
    if (prevRate !== undefined) process.env.SENTRY_TRACES_SAMPLE_RATE = prevRate;
  });

  it("tags events with the app name", () => {
    initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
    const call = (Sentry.init as any).mock.calls[0][0];
    expect(call.initialScope?.tags?.app).toBe("web-next");
  });

  it("is a no-op when dsn is empty/undefined", () => {
    initSentryServer({ dsn: "", app: "web-next" });
    expect(Sentry.init).not.toHaveBeenCalled();
    initSentryServer({ dsn: undefined as any, app: "web-next" });
    expect(Sentry.init).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @repo/core-shared test init-server`
Expected: FAIL — `initSentryServer` not found.

- [ ] **Step 3: Implement init-server**

```ts
// packages/core-shared/src/instrumentation/sentry/init-server.ts
import * as Sentry from "@sentry/nextjs";
import { beforeSend, beforeSendTransaction } from "./scrub";

export type InitServerOpts = {
  dsn: string | undefined;
  app: "web-next" | "cms" | "web-tanstack";
  release?: string;
};

export function initSentryServer(opts: InitServerOpts): void {
  if (!opts.dsn) return;

  const isProd = process.env.NODE_ENV === "production";
  const tracesSampleRate =
    process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined
      ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE)
      : isProd
        ? 0.1
        : 1.0;

  const environment = process.env.SENTRY_ENVIRONMENT ?? process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development";
  const release = opts.release ?? process.env.VERCEL_GIT_COMMIT_SHA ?? "unknown";

  Sentry.init({
    dsn: opts.dsn,
    environment,
    release,
    tracesSampleRate,
    sendDefaultPii: false, // R31 — non-negotiable
    beforeSend, // R32
    beforeSendTransaction, // R33
    initialScope: { tags: { app: opts.app } },
  });
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm --filter @repo/core-shared test init-server`
Expected: PASS — 7 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/core-shared/src/instrumentation/sentry/init-server.ts \
        packages/core-shared/src/instrumentation/sentry/init-server.test.ts
git commit -m "feat(core-shared): initSentryServer helper (R31, R32, R33, R37 defaults)"
```

---

### Task 11: `init-client` helper (browser-only)

**Files:**
- Create: `packages/core-shared/src/instrumentation/sentry/init-client.ts`
- Create: `packages/core-shared/src/instrumentation/sentry/init-client.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-shared/src/instrumentation/sentry/init-client.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";

const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts }));

vi.mock("@sentry/nextjs", () => ({
  init: vi.fn(),
  replayIntegration,
}));

import * as Sentry from "@sentry/nextjs";
import { initSentryClient } from "@/instrumentation/sentry/init-client";

describe("initSentryClient", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("calls Sentry.init with sendDefaultPii: false (R31)", () => {
    initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
    const call = (Sentry.init as any).mock.calls[0][0];
    expect(call.sendDefaultPii).toBe(false);
  });

  it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true (R34, R35)", () => {
    initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
    expect(replayIntegration).toHaveBeenCalledTimes(1);
    const replayOpts = (replayIntegration as any).mock.calls[0][0];
    expect(replayOpts.maskAllText).toBe(true);
    expect(replayOpts.maskAllInputs).toBe(true);
    expect(replayOpts.blockAllMedia).toBe(true);
  });

  it("defaults replaysSessionSampleRate to 0.0 (R37)", () => {
    initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
    const call = (Sentry.init as any).mock.calls[0][0];
    expect(call.replaysSessionSampleRate).toBe(0.0);
  });

  it("defaults replaysOnErrorSampleRate to 1.0 (R37)", () => {
    initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
    const call = (Sentry.init as any).mock.calls[0][0];
    expect(call.replaysOnErrorSampleRate).toBe(1.0);
  });

  it("attaches beforeSend + beforeSendTransaction", () => {
    initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
    const call = (Sentry.init as any).mock.calls[0][0];
    expect(typeof call.beforeSend).toBe("function");
    expect(typeof call.beforeSendTransaction).toBe("function");
  });

  it("is a no-op when dsn is empty", () => {
    initSentryClient({ dsn: "", app: "web-next" });
    expect(Sentry.init).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @repo/core-shared test init-client`
Expected: FAIL — `initSentryClient` not found.

- [ ] **Step 3: Implement init-client**

```ts
// packages/core-shared/src/instrumentation/sentry/init-client.ts
import * as Sentry from "@sentry/nextjs";
import { beforeSend, beforeSendTransaction } from "./scrub";

export type InitClientOpts = {
  dsn: string | undefined;
  app: "web-next" | "cms" | "web-tanstack";
  release?: string;
};

export function initSentryClient(opts: InitClientOpts): void {
  if (!opts.dsn) return;

  const isProd = process.env.NODE_ENV === "production";
  const tracesSampleRate =
    process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined
      ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE)
      : isProd
        ? 0.1
        : 1.0;

  const environment = process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development";
  const release = opts.release ?? "unknown";

  Sentry.init({
    dsn: opts.dsn,
    environment,
    release,
    tracesSampleRate,
    sendDefaultPii: false, // R31
    beforeSend, // R32
    beforeSendTransaction, // R33
    replaysSessionSampleRate: 0.0, // R37 — privacy default
    replaysOnErrorSampleRate: 1.0, // R37
    integrations: [
      // R34, R35 — mandatory mask flags; allowlist starts empty
      Sentry.replayIntegration({
        maskAllText: true,
        maskAllInputs: true,
        blockAllMedia: true,
      }),
    ],
    initialScope: { tags: { app: opts.app } },
  });
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm --filter @repo/core-shared test init-client`
Expected: PASS — 6 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/core-shared/src/instrumentation/sentry/init-client.ts \
        packages/core-shared/src/instrumentation/sentry/init-client.test.ts
git commit -m "feat(core-shared): initSentryClient helper (R34, R35, R37 mandatory replay defaults)"
```

---

## Phase C — DI binders + dispatcher

> **Design note:** The instrumentation symbols (TRACER, LOGGER) live in `core-shared/instrumentation/symbols.ts`. The two binders accept a Container, bind both symbols, and (for `bindSentry`) call `initSentryServer` first. Apps construct `tracer` and `logger` once at boot, then pass them to every feature binder as parameters. This dual approach (container binding *and* parameter passing) keeps internal-resolution code working while letting feature factories receive instances without container lookups.

### Task 12: bindNoopInstrumentation + bindSentryInstrumentation

**Files:**
- Create: `packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts`
- Create: `packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.test.ts`
- Create: `packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts`
- Create: `packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.test.ts`
- Modify: `packages/core-shared/src/instrumentation/index.ts` (re-export the binders)
- Modify: `packages/core-shared/package.json` (add `./instrumentation/di/*` subpath if needed; the broad `./instrumentation` export should already cover this)

- [ ] **Step 1: Write the failing test for bindNoopInstrumentation**

```ts
// packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.test.ts
import "reflect-metadata";
import { describe, it, expect } from "vitest";
import { Container } from "inversify";
import { bindNoopInstrumentation } from "@/instrumentation/di/bind-noop-instrumentation";
import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols";
import { NoopTracer } from "@/instrumentation/noop-tracer";
import { NoopLogger } from "@/instrumentation/noop-logger";
import type { ITracer, ILogger } from "@/instrumentation";

describe("bindNoopInstrumentation", () => {
  it("returns a tracer + logger pair", () => {
    const c = new Container();
    const { tracer, logger } = bindNoopInstrumentation(c);
    expect(tracer).toBeInstanceOf(NoopTracer);
    expect(logger).toBeInstanceOf(NoopLogger);
  });

  it("binds TRACER and LOGGER symbols on the container", () => {
    const c = new Container();
    bindNoopInstrumentation(c);
    const tracer = c.get<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**

```ts
// packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts
import type { Container } from "inversify";
import { NoopTracer } from "../noop-tracer";
import { NoopLogger } from "../noop-logger";
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
import type { ITracer, ILogger } from "../index";

export function bindNoopInstrumentation(container: Container): {
  tracer: ITracer;
  logger: ILogger;
} {
  const tracer = new NoopTracer();
  const logger = new NoopLogger();
  if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
    container.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
  }
  if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
    container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
  }
  container.bind<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**

```ts
// packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.test.ts
import "reflect-metadata";
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@sentry/nextjs", () => ({
  init: vi.fn(),
  startSpan: vi.fn((_opts, fn) => fn({ setAttribute: vi.fn(), setStatus: vi.fn() })),
  captureException: vi.fn(),
  captureMessage: vi.fn(),
  addBreadcrumb: vi.fn(),
  setUser: vi.fn(),
  replayIntegration: vi.fn(() => ({ name: "Replay" })),
}));

import * as Sentry from "@sentry/nextjs";
import { Container } from "inversify";
import { bindSentryInstrumentation } from "@/instrumentation/di/bind-sentry-instrumentation";
import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols";
import { SentryTracer } from "@/instrumentation/sentry/sentry-tracer";
import { SentryLogger } from "@/instrumentation/sentry/sentry-logger";

describe("bindSentryInstrumentation", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("calls Sentry.init via initSentryServer", () => {
    const c = new Container();
    bindSentryInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" });
    expect(Sentry.init).toHaveBeenCalledTimes(1);
    expect((Sentry.init as any).mock.calls[0][0].dsn).toBe("https://x@y/1");
  });

  it("binds SentryTracer + SentryLogger to the container", () => {
    const c = new Container();
    bindSentryInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" });
    expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBeInstanceOf(SentryTracer);
    expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBeInstanceOf(SentryLogger);
  });

  it("returns the tracer + logger instances", () => {
    const c = new Container();
    const { tracer, logger } = bindSentryInstrumentation(c, {
      dsn: "https://x@y/1",
      app: "web-next",
    });
    expect(tracer).toBeInstanceOf(SentryTracer);
    expect(logger).toBeInstanceOf(SentryLogger);
  });
});
```

- [ ] **Step 6: Run test to verify it fails**

Run: `pnpm --filter @repo/core-shared test bind-sentry-instrumentation`
Expected: FAIL — `bindSentryInstrumentation` not found.

- [ ] **Step 7: Implement bindSentryInstrumentation**

```ts
// packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts
import type { Container } from "inversify";
import { SentryTracer } from "../sentry/sentry-tracer";
import { SentryLogger } from "../sentry/sentry-logger";
import { initSentryServer, type InitServerOpts } from "../sentry/init-server";
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
import type { ITracer, ILogger } from "../index";

export type BindSentryOpts = InitServerOpts;

export function bindSentryInstrumentation(
  container: Container,
  opts: BindSentryOpts,
): { tracer: ITracer; logger: ILogger } {
  initSentryServer(opts);

  const tracer = new SentryTracer();
  const logger = new SentryLogger();

  if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
    container.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
  }
  if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
    container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
  }
  container.bind<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`:

```ts
export { bindNoopInstrumentation } from "./di/bind-noop-instrumentation";
export {
  bindSentryInstrumentation,
  type BindSentryOpts,
} from "./di/bind-sentry-instrumentation";
```

- [ ] **Step 10: Build to verify barrel**

Run: `pnpm --filter @repo/core-shared build`
Expected: build succeeds.

- [ ] **Step 11: Commit**

```bash
git add packages/core-shared/src/instrumentation/di/ \
        packages/core-shared/src/instrumentation/index.ts
git commit -m "feat(core-shared): bindNoopInstrumentation + bindSentryInstrumentation"
```

---

### Task 13: `bindAll()` Rule 0 dispatcher (apps/web-next)

**Files:**
- Modify: `apps/web-next/src/server/bind-production.ts`

> **Note:** Rule 0 wires instrumentation into `bindAll()`, but feature binders haven't been updated yet (those land in Phase E). For now, `bindAll()` constructs tracer + logger and stores them in module-scope variables for downstream binders to read. Per-feature binder signatures change in Phase E to accept tracer/logger as parameters.

- [ ] **Step 1: Update bind-production.ts**

Replace the contents of `apps/web-next/src/server/bind-production.ts` with the following. The header comment changes; the existing `bindAllProduction` and `bindAllDevSeed` keep their current signatures (we'll wire tracer/logger threading in Phase E). For now, just add the Rule 0 instrumentation step to `bindAll()`:

```ts
// apps/web-next/src/server/bind-production.ts
// SERVER-ONLY: this module imports Payload config and must never be bundled into the browser.
import "reflect-metadata";
import { Container } from "inversify";
import config from "@repo/core-cms";
import {
  bindNoopInstrumentation,
  bindSentryInstrumentation,
  type ITracer,
  type ILogger,
} from "@repo/core-shared/instrumentation";
import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { bindProductionAuth } from "@repo/auth/di/bind-production";
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
import { bindProductionNavigation } from "@repo/navigation/di/bind-production";
import { bindProductionMedia } from "@repo/media/di/bind-production";
import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed";
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed";
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";

let bound = false;

// Shared container holds TRACER + LOGGER bindings; per-feature containers
// receive references via parameter passing. This separates the instrumentation
// container (one) from feature containers (per-feature, ADR-008).
const sharedContainer = new Container();

let resolvedTracer: ITracer | null = null;
let resolvedLogger: ILogger | null = null;

/** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */
function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
  if (resolvedTracer && resolvedLogger) {
    return { tracer: resolvedTracer, logger: resolvedLogger };
  }
  const dsn = process.env.WEB_NEXT_SENTRY_DSN;
  const result = dsn
    ? bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" })
    : bindNoopInstrumentation(sharedContainer);
  resolvedTracer = result.tracer;
  resolvedLogger = result.logger;
  return result;
}

/**
 * Production path: swap each feature's mock repository binding for the real
 * Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
 * feature once Phase E feature wiring lands. Until Phase E, this still calls
 * the existing `bindProductionX(config)` signature; the unused `tracer`/`logger`
 * are forwarded by Phase E commits as those binders are updated.
 */
export async function bindAllProduction(): Promise<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**

```bash
git add apps/web-next/src/server/bind-production.ts
git commit -m "feat(app): bindAll() Rule 0 — DSN-driven instrumentation (orthogonal to repo mode)"
```

---

### Task 14: Tests for `bindAll()` orthogonality (R47)

**Files:**
- Modify: `apps/web-next/src/server/bind-production.test.ts`

- [ ] **Step 1: Add new test block to existing file**

Append the following describe block to `apps/web-next/src/server/bind-production.test.ts` (after the existing two describes):

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

```bash
git add apps/web-next/src/server/bind-production.test.ts
git commit -m "test(app): R47 — bindAll instrumentation orthogonality matrix"
```

---

## Phase D — Test infrastructure (core-testing)

> **Implementation note about R49:** The spec's R49 says "vitest setup MUST bind Noop by default." In practice, the codebase has no shared test container — repositories construct themselves directly with `new MockXRepository()`. Therefore "default Noop" is enforced by *default constructor parameters* on every repo (Phase E task work). What `core-testing` provides is a `RecordingTracer` and `RecordingLogger` for tests that want to assert capture/span calls, plus a setup-side guard that fails if Sentry's real SDK accidentally initializes during a test process.

### Task 15: RecordingTracer

**Files:**
- Create: `packages/core-testing/src/instrumentation/recording-tracer.ts`
- Create: `packages/core-testing/src/instrumentation/recording-tracer.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-testing/src/instrumentation/recording-tracer.test.ts
import { describe, it, expect } from "vitest";
import { RecordingTracer } from "@/instrumentation/recording-tracer";

describe("RecordingTracer", () => {
  it("records every startSpan call with name, op, attributes, status, durationMs", async () => {
    const tracer = new RecordingTracer();
    await tracer.startSpan(
      { name: "blog.getArticles", op: "use-case", attributes: { limit: 10 } },
      async (span) => {
        span.setAttribute("count", 3);
        span.setStatus("ok");
        return undefined;
      },
    );
    expect(tracer.spans).toHaveLength(1);
    const s = tracer.spans[0]!;
    expect(s.name).toBe("blog.getArticles");
    expect(s.op).toBe("use-case");
    expect(s.attributes).toMatchObject({ limit: 10, count: 3 });
    expect(s.status).toBe("ok");
    expect(typeof s.durationMs).toBe("number");
    expect(s.durationMs).toBeGreaterThanOrEqual(0);
  });

  it("records error status when fn throws", async () => {
    const tracer = new RecordingTracer();
    await expect(
      tracer.startSpan({ name: "x" }, async () => {
        throw new Error("boom");
      }),
    ).rejects.toThrow("boom");
    expect(tracer.spans).toHaveLength(1);
    expect(tracer.spans[0]!.status).toBe("error");
    expect(tracer.spans[0]!.statusMessage).toBe("boom");
  });

  it("records error status when set explicitly via span.setStatus", async () => {
    const tracer = new RecordingTracer();
    await tracer.startSpan({ name: "x" }, async (span) => {
      span.setStatus("error", "validation failed");
      return undefined;
    });
    expect(tracer.spans[0]!.status).toBe("error");
    expect(tracer.spans[0]!.statusMessage).toBe("validation failed");
  });

  it("reset() clears recorded spans", async () => {
    const tracer = new RecordingTracer();
    await tracer.startSpan({ name: "x" }, async () => undefined);
    expect(tracer.spans).toHaveLength(1);
    tracer.reset();
    expect(tracer.spans).toHaveLength(0);
  });

  it("findSpan returns first matching span by name", async () => {
    const tracer = new RecordingTracer();
    await tracer.startSpan({ name: "a" }, async () => undefined);
    await tracer.startSpan({ name: "b" }, async () => undefined);
    expect(tracer.findSpan("b")).toBeDefined();
    expect(tracer.findSpan("missing")).toBeUndefined();
  });

  it("nested spans are recorded in order (children appear after parent end)", async () => {
    const tracer = new RecordingTracer();
    await tracer.startSpan({ name: "parent" }, async () => {
      await tracer.startSpan({ name: "child" }, async () => undefined);
    });
    expect(tracer.spans.map((s) => s.name)).toEqual(["child", "parent"]);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @repo/core-testing test recording-tracer`
Expected: FAIL — `RecordingTracer` not found.

- [ ] **Step 3: Implement RecordingTracer**

```ts
// packages/core-testing/src/instrumentation/recording-tracer.ts
import type {
  ITracer,
  ISpan,
  SpanOpts,
  AttributeValue,
} from "@repo/core-shared/instrumentation";

export type RecordedSpan = {
  name: string;
  op?: string;
  attributes: Record<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**

```bash
git add packages/core-testing/src/instrumentation/recording-tracer.ts \
        packages/core-testing/src/instrumentation/recording-tracer.test.ts
git commit -m "feat(core-testing): RecordingTracer for span assertions"
```

---

### Task 16: RecordingLogger + barrel

**Files:**
- Create: `packages/core-testing/src/instrumentation/recording-logger.ts`
- Create: `packages/core-testing/src/instrumentation/recording-logger.test.ts`
- Create: `packages/core-testing/src/instrumentation/index.ts`
- Modify: `packages/core-testing/src/index.ts` (re-export)
- Modify: `packages/core-testing/package.json` (add `./instrumentation` subpath)

- [ ] **Step 1: Write the failing test**

```ts
// packages/core-testing/src/instrumentation/recording-logger.test.ts
import { describe, it, expect } from "vitest";
import { RecordingLogger } from "@/instrumentation/recording-logger";

describe("RecordingLogger", () => {
  it("records captureException calls (err + ctx)", () => {
    const logger = new RecordingLogger();
    const err = new Error("x");
    logger.captureException(err, { tags: { feature: "blog" } });
    expect(logger.captures).toHaveLength(1);
    expect(logger.captures[0]).toMatchObject({
      kind: "exception",
      err,
      ctx: { tags: { feature: "blog" } },
    });
  });

  it("records captureMessage calls", () => {
    const logger = new RecordingLogger();
    logger.captureMessage("hello", "warning", { extras: { foo: 1 } });
    expect(logger.captures[0]).toMatchObject({
      kind: "message",
      message: "hello",
      level: "warning",
    });
  });

  it("records breadcrumbs", () => {
    const logger = new RecordingLogger();
    logger.addBreadcrumb({ category: "test", message: "x", level: "info" });
    expect(logger.breadcrumbs).toHaveLength(1);
    expect(logger.breadcrumbs[0]!.category).toBe("test");
  });

  it("records setUser calls", () => {
    const logger = new RecordingLogger();
    logger.setUser({ id: "u1" });
    logger.setUser(null);
    expect(logger.users).toEqual([{ id: "u1" }, null]);
  });

  it("reset() clears all recordings", () => {
    const logger = new RecordingLogger();
    logger.captureException(new Error("x"));
    logger.addBreadcrumb({ category: "c", message: "m" });
    logger.setUser({ id: "u" });
    logger.reset();
    expect(logger.captures).toHaveLength(0);
    expect(logger.breadcrumbs).toHaveLength(0);
    expect(logger.users).toHaveLength(0);
  });

  it("findCapture returns first capture matching predicate", () => {
    const logger = new RecordingLogger();
    logger.captureException(new Error("first"));
    logger.captureException(new Error("second"));
    const found = logger.findCapture(
      (c) => c.kind === "exception" && (c.err as Error).message === "second",
    );
    expect(found).toBeDefined();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @repo/core-testing test recording-logger`
Expected: FAIL — `RecordingLogger` not found.

- [ ] **Step 3: Implement RecordingLogger**

```ts
// packages/core-testing/src/instrumentation/recording-logger.ts
import type {
  ILogger,
  Breadcrumb,
  CaptureContext,
} from "@repo/core-shared/instrumentation";

export type RecordedCapture =
  | { kind: "exception"; err: unknown; ctx?: CaptureContext }
  | { kind: "message"; message: string; level?: "info" | "warning" | "error"; ctx?: CaptureContext };

export class RecordingLogger implements ILogger {
  captures: RecordedCapture[] = [];
  breadcrumbs: Breadcrumb[] = [];
  users: Array<{ id: string } | null> = [];

  captureException(err: unknown, ctx?: CaptureContext): void {
    this.captures.push({ kind: "exception", err, ctx });
  }

  captureMessage(
    message: string,
    level?: "info" | "warning" | "error",
    ctx?: CaptureContext,
  ): void {
    this.captures.push({ kind: "message", message, level, ctx });
  }

  addBreadcrumb(b: Breadcrumb): void {
    this.breadcrumbs.push(b);
  }

  setUser(user: { id: string } | null): void {
    this.users.push(user);
  }

  reset(): void {
    this.captures = [];
    this.breadcrumbs = [];
    this.users = [];
  }

  findCapture(
    predicate: (c: RecordedCapture) => boolean,
  ): RecordedCapture | undefined {
    return this.captures.find(predicate);
  }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm --filter @repo/core-testing test recording-logger`
Expected: PASS — 6 tests.

- [ ] **Step 5: Write the barrel**

```ts
// packages/core-testing/src/instrumentation/index.ts
export { RecordingTracer, type RecordedSpan } from "./recording-tracer";
export { RecordingLogger, type RecordedCapture } from "./recording-logger";
```

- [ ] **Step 6: Re-export from package root**

Append to `packages/core-testing/src/index.ts`:

```ts
export * from "./instrumentation/index.js";
```

- [ ] **Step 7: Add subpath export in package.json**

Update `packages/core-testing/package.json` `exports` field — add the entry while preserving existing entries:

```json
{
  "exports": {
    ".": "./src/index.ts",
    "./factory": "./src/factory/index.ts",
    "./contract": "./src/contract/index.ts",
    "./instrumentation": "./src/instrumentation/index.ts",
    "./react": "./src/react/index.ts",
    "./payload": "./src/payload/index.ts",
    "./payload/stub-config": "./src/payload/stub-config.ts",
    "./setup/jsdom": "./src/setup/jsdom.ts",
    "./setup/node": "./src/setup/node.ts"
  }
}
```

- [ ] **Step 8: Verify build + import**

Run: `pnpm --filter @repo/core-testing typecheck`
Expected: passes.

- [ ] **Step 9: Commit**

```bash
git add packages/core-testing/src/instrumentation/ \
        packages/core-testing/src/index.ts \
        packages/core-testing/package.json
git commit -m "feat(core-testing): RecordingLogger + ./instrumentation subpath"
```

---

### Task 17: vitest setup guard — fail if real Sentry initializes

**Files:**
- Create: `packages/core-testing/src/setup/no-sentry.ts`
- Modify: `packages/core-testing/src/setup/jsdom.ts` (import the guard)
- Modify: `packages/core-testing/src/setup/node.ts` (import the guard)
- Modify: `packages/core-testing/package.json` (export the new file if needed)

> **Why:** R49 forbids real Sentry SDK initialization in test processes. This guard mocks `@sentry/nextjs` globally so any code that imports it gets a no-op surface — including code that wasn't written with testing in mind. Tests that use SentryTracer/SentryLogger directly already mock the module per-file (Tasks 7, 8); this guard is a safety net for indirect imports.

- [ ] **Step 1: Write the guard**

```ts
// packages/core-testing/src/setup/no-sentry.ts
import { vi } from "vitest";

/**
 * R49 — guard against real Sentry SDK initialization in test processes.
 *
 * Mocks @sentry/nextjs at the module level so any code that imports it
 * receives a no-op surface. Tests that need to assert Sentry behavior
 * still use vi.mock locally with their own implementation; this guard
 * just ensures *unintentional* imports don't cause real network/init.
 */
vi.mock("@sentry/nextjs", () => ({
  init: vi.fn(),
  startSpan: vi.fn((_opts: unknown, fn: any) =>
    fn({ setAttribute: vi.fn(), setStatus: vi.fn() }),
  ),
  captureException: vi.fn(),
  captureMessage: vi.fn(),
  addBreadcrumb: vi.fn(),
  setUser: vi.fn(),
  setContext: vi.fn(),
  setTag: vi.fn(),
  setExtra: vi.fn(),
  withScope: vi.fn((fn: any) => fn({ setTag: vi.fn(), setExtra: vi.fn() })),
  replayIntegration: vi.fn(() => ({ name: "Replay" })),
  getActiveSpan: vi.fn(() => undefined),
  getCurrentHub: vi.fn(() => ({ getClient: () => undefined })),
}));
```

- [ ] **Step 2: Import from existing setup files**

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

```ts
import "./no-sentry";
```

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

```ts
import "./no-sentry";
```

- [ ] **Step 3: Verify no test regressions**

Run: `pnpm test` (full monorepo)
Expected: no new failures. Existing tests pass.

- [ ] **Step 4: Add a positive test for the guard**

Create `packages/core-testing/src/setup/no-sentry.test.ts`:

```ts
import { describe, it, expect } from "vitest";
import * as Sentry from "@sentry/nextjs";

describe("setup/no-sentry guard (R49)", () => {
  it("Sentry.init is a vi.fn (mocked, not real)", () => {
    expect(vi.isMockFunction(Sentry.init)).toBe(true);
  });

  it("Sentry.captureException is a vi.fn", () => {
    expect(vi.isMockFunction(Sentry.captureException)).toBe(true);
  });

  it("calling Sentry.init does not throw or initialize", () => {
    expect(() => Sentry.init({ dsn: "https://x@y/1" } as any)).not.toThrow();
  });
});
```

Run: `pnpm --filter @repo/core-testing test no-sentry`
Expected: PASS — 3 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/core-testing/src/setup/no-sentry.ts \
        packages/core-testing/src/setup/no-sentry.test.ts \
        packages/core-testing/src/setup/jsdom.ts \
        packages/core-testing/src/setup/node.ts
git commit -m "feat(core-testing): R49 guard — block real Sentry SDK init in test processes"
```

---

## Phase E — Per-feature wiring

> **Pattern reference (applies to every feature task in this phase):**
>
> 1. **Real repository class** — constructor signature changes from `(config)` to `(config, tracer = new NoopTracer(), logger = new NoopLogger())`. Each public async method's body is wrapped:
>
>    ```ts
>    async findX(args): Promise<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:
>
>    ```ts
>    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)**

```ts
// packages/blog/src/infrastructure/repositories/articles.repository.ts
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import {
  NoopTracer,
  NoopLogger,
  type ITracer,
  type ILogger,
} from "@repo/core-shared/instrumentation";

import type { IArticlesRepository } from "../../application/repositories/articles.repository.interface";
import type { Article } from "../../entities/models/article";

type PayloadArticleDoc = {
  id: string | number;
  title?: string | null;
  slug?: string | null;
  content?: unknown;
  status?: string | null;
  author?: string | number | { id: string | number } | null;
  createdAt?: string | null;
  updatedAt?: string | null;
};

function mapDoc(doc: PayloadArticleDoc): Article {
  const authorId =
    typeof doc.author === "object" && doc.author !== null
      ? String(doc.author.id)
      : doc.author != null
        ? String(doc.author)
        : "";
  return {
    id: String(doc.id),
    title: doc.title ?? "",
    slug: doc.slug ?? "",
    content: doc.content ?? null,
    status: doc.status === "published" ? "published" : "draft",
    authorId,
    createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0),
    updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0),
  };
}

const FEATURE = "blog" as const;
const REPO = "articles" as const;

@injectable()
export class ArticlesRepository implements IArticlesRepository {
  private config: SanitizedConfig;
  private tracer: ITracer;
  private logger: ILogger;

  constructor(
    config: SanitizedConfig,
    tracer: ITracer = new NoopTracer(),
    logger: ILogger = new NoopLogger(),
  ) {
    this.config = config;
    this.tracer = tracer;
    this.logger = logger;
  }

  async getArticle(id: string): Promise<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**

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

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

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

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

```ts
import { describe, it, expect } from "vitest";
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";

// Mock repo also wraps in spans (R42); easier to assert without booting Payload.
describe("MockArticlesRepository emits spans (R42)", () => {
  it("getArticles emits one span with op='repository'", async () => {
    const tracer = new RecordingTracer();
    const logger = new RecordingLogger();
    const repo = new MockArticlesRepository(tracer, logger);
    await repo.getArticles({ limit: 10 });
    expect(tracer.spans).toHaveLength(1);
    expect(tracer.spans[0]).toMatchObject({
      name: "articles.getArticles",
      op: "repository",
    });
    expect(tracer.spans[0]!.attributes).toMatchObject({ limit: 10 });
  });

  it("createArticle emits a span with slug attribute", async () => {
    const tracer = new RecordingTracer();
    const repo = new MockArticlesRepository(tracer);
    await repo.createArticle({
      id: "a1",
      title: "T",
      slug: "t",
      content: null,
      status: "draft",
      authorId: "u1",
      createdAt: new Date(),
      updatedAt: new Date(),
    });
    expect(tracer.findSpan("articles.createArticle")).toBeDefined();
    expect(tracer.findSpan("articles.createArticle")!.attributes.slug).toBe("t");
  });

  it("getArticle records found=false for missing id", async () => {
    const tracer = new RecordingTracer();
    const repo = new MockArticlesRepository(tracer);
    await repo.getArticle("missing");
    expect(tracer.spans[0]!.attributes.found).toBe(false);
  });
});
```

Run: `pnpm --filter @repo/blog test articles.repository.span`
Expected: PASS — 3 tests.

- [ ] **Step 8: Commit**

```bash
git add packages/blog/src/infrastructure/repositories/articles.repository.ts \
        packages/blog/src/infrastructure/repositories/articles.repository.mock.ts \
        packages/blog/src/infrastructure/repositories/articles.repository.span.test.ts \
        packages/blog/src/di/bind-production.ts \
        packages/blog/src/di/bind-dev-seed.ts \
        apps/web-next/src/server/bind-production.ts
git commit -m "feat(blog): wire instrumentation — repo spans + use-case/controller withSpan + logger capture"
```

---

### Task 19: Auth feature wiring

**Files:**
- Modify: `packages/auth/src/infrastructure/repositories/users.repository.ts`
- Modify: `packages/auth/src/infrastructure/repositories/users.repository.mock.ts`
- Modify: `packages/auth/src/di/bind-production.ts`
- Modify: `packages/auth/src/di/bind-dev-seed.ts`
- Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger to auth binders)

**Use cases:** `auth.signIn`, `auth.signUp`, `auth.signOut` (all 3, `op: "use-case"` and `op: "controller"`).

**Repository methods:** read the actual file first (`packages/auth/src/infrastructure/repositories/users.repository.ts`) and apply the wrapping pattern from Phase E preamble to every public async method. Span name format: `users.<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:

```ts
attributes: { emailDomain: email.split("@")[1] ?? "(invalid)" }
```

This avoids putting raw email in trace data even though scrubbers would catch it downstream.

- [ ] **Step 1: Read existing files**

Open each file and note the method signatures + bodies:
- `packages/auth/src/infrastructure/repositories/users.repository.ts`
- `packages/auth/src/infrastructure/repositories/users.repository.mock.ts`
- `packages/auth/src/di/bind-production.ts`
- `packages/auth/src/di/bind-dev-seed.ts`

- [ ] **Step 2: Apply Phase E preamble pattern to real users.repository.ts**

For every public async method, wrap the body in `tracer.startSpan({ name: "users.<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**

```ts
// packages/auth/src/di/bind-production.ts
import type { SanitizedConfig } from "payload";
import {
  withSpan,
  INSTRUMENTATION_SYMBOLS,
  type ITracer,
  type ILogger,
} from "@repo/core-shared/instrumentation";
import { authContainer } from "./container";
import { AUTH_SYMBOLS } from "./symbols";
import { UsersRepository } from "../infrastructure/repositories/users.repository";
import { signInUseCase } from "../application/use-cases/sign-in.use-case";
import { signUpUseCase } from "../application/use-cases/sign-up.use-case";
import { signOutUseCase } from "../application/use-cases/sign-out.use-case";
import { signInController } from "../interface-adapters/controllers/sign-in.controller";
import { signUpController } from "../interface-adapters/controllers/sign-up.controller";
import { signOutController } from "../interface-adapters/controllers/sign-out.controller";

export function bindProductionAuth(
  config: SanitizedConfig,
  tracer: ITracer,
  logger: ILogger,
): void {
  // Bind shared instrumentation
  for (const sym of [INSTRUMENTATION_SYMBOLS.TRACER, INSTRUMENTATION_SYMBOLS.LOGGER]) {
    if (authContainer.isBound(sym)) authContainer.unbind(sym);
  }
  authContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
  authContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);

  // Repository
  if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
    authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
  }
  const repo = new UsersRepository(config, tracer, logger);
  authContainer.bind(AUTH_SYMBOLS.IUsersRepository).toConstantValue(repo);

  // The auth feature also has IAuthenticationService — read existing bind-production to
  // understand what additional services exist; bind them similarly.
  // (If auth's bind-production binds an authentication service, follow the same pattern.)

  // Use cases
  const wrappedSignIn = withSpan(
    tracer,
    { name: "auth.signIn", op: "use-case" },
    signInUseCase(repo /*, ...other deps */),
  );
  const wrappedSignUp = withSpan(
    tracer,
    { name: "auth.signUp", op: "use-case" },
    signUpUseCase(repo /*, ...other deps */),
  );
  const wrappedSignOut = withSpan(
    tracer,
    { name: "auth.signOut", op: "use-case" },
    signOutUseCase(/* deps */),
  );

  for (const sym of [
    AUTH_SYMBOLS.SignInUseCase,
    AUTH_SYMBOLS.SignUpUseCase,
    AUTH_SYMBOLS.SignOutUseCase,
    AUTH_SYMBOLS.SignInController,
    AUTH_SYMBOLS.SignUpController,
    AUTH_SYMBOLS.SignOutController,
  ]) {
    if (authContainer.isBound(sym)) authContainer.unbind(sym);
  }
  authContainer.bind(AUTH_SYMBOLS.SignInUseCase).toConstantValue(wrappedSignIn);
  authContainer.bind(AUTH_SYMBOLS.SignUpUseCase).toConstantValue(wrappedSignUp);
  authContainer.bind(AUTH_SYMBOLS.SignOutUseCase).toConstantValue(wrappedSignOut);

  // Controllers
  authContainer
    .bind(AUTH_SYMBOLS.SignInController)
    .toConstantValue(
      withSpan(
        tracer,
        { name: "auth.signIn", op: "controller" },
        signInController(wrappedSignIn),
      ),
    );
  authContainer
    .bind(AUTH_SYMBOLS.SignUpController)
    .toConstantValue(
      withSpan(
        tracer,
        { name: "auth.signUp", op: "controller" },
        signUpController(wrappedSignUp),
      ),
    );
  authContainer
    .bind(AUTH_SYMBOLS.SignOutController)
    .toConstantValue(
      withSpan(
        tracer,
        { name: "auth.signOut", op: "controller" },
        signOutController(wrappedSignOut),
      ),
    );
}
```

> **Note:** The existing `bind-production.ts` may bind additional dependencies (e.g., `IAuthenticationService`). Read the current file first and preserve any bindings beyond the repository — apply the same `tracer`/`logger` constructor extension to those service classes too if they're feature-owned implementations.

- [ ] **Step 4: Update bind-dev-seed.ts**

Apply Phase E preamble pattern: signature `(tracer, logger)`, mock repo gets tracer/logger, every use case + controller is wrapped via `withSpan`. Use Task 18's `bind-dev-seed.ts` as structural reference.

- [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts**

```ts
// In bindAllProduction:
bindProductionAuth(resolvedConfig, tracer, logger);

// In bindAllDevSeed:
await bindDevSeedAuth(tracer, logger);
```

- [ ] **Step 6: Span-shape test for the auth mock repo**

Create `packages/auth/src/infrastructure/repositories/users.repository.span.test.ts` matching Task 18 Step 7 — assert at least one method emits a `users.<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**

```bash
git add packages/auth/src/ apps/web-next/src/server/bind-production.ts
git commit -m "feat(auth): wire instrumentation — users repo spans + sign-in/up/out withSpan"
```

---

### Task 20: Marketing-pages feature wiring

**Files:**
- Modify: `packages/marketing-pages/src/infrastructure/repositories/site-settings.repository.ts`
- Modify: `packages/marketing-pages/src/infrastructure/repositories/site-settings.repository.mock.ts`
- Modify: `packages/marketing-pages/src/infrastructure/repositories/pages.repository.ts`
- Modify: `packages/marketing-pages/src/infrastructure/repositories/pages.repository.mock.ts`
- Modify: `packages/marketing-pages/src/di/bind-production.ts`
- Modify: `packages/marketing-pages/src/di/bind-dev-seed.ts`
- Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger)

**Two repos:** site-settings + pages.
**Use cases:** `marketing-pages.getSiteSettings`, `marketing-pages.getPageBySlug`.

- [ ] **Step 1: Read existing files** (the 6 listed above + use-case + controller files).

- [ ] **Step 2: Apply Phase E preamble pattern to both repositories** (real + mock for each), spans named `site-settings.<method>` and `pages.<method>`.

- [ ] **Step 3: Update bind-production.ts**

```ts
// packages/marketing-pages/src/di/bind-production.ts (skeleton)
import type { SanitizedConfig } from "payload";
import { withSpan, INSTRUMENTATION_SYMBOLS, type ITracer, type ILogger } from "@repo/core-shared/instrumentation";
import { marketingPagesContainer } from "./container";
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
import { SiteSettingsRepository } from "../infrastructure/repositories/site-settings.repository";
import { PagesRepository } from "../infrastructure/repositories/pages.repository";
import { getSiteSettingsUseCase } from "../application/use-cases/get-site-settings.use-case";
import { getPageBySlugUseCase } from "../application/use-cases/get-page-by-slug.use-case";
import { getSiteSettingsController } from "../interface-adapters/controllers/get-site-settings.controller";
import { getPageBySlugController } from "../interface-adapters/controllers/get-page-by-slug.controller";

export function bindProductionMarketingPages(
  config: SanitizedConfig,
  tracer: ITracer,
  logger: ILogger,
): void {
  for (const sym of [INSTRUMENTATION_SYMBOLS.TRACER, INSTRUMENTATION_SYMBOLS.LOGGER]) {
    if (marketingPagesContainer.isBound(sym)) marketingPagesContainer.unbind(sym);
  }
  marketingPagesContainer.bind(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
  marketingPagesContainer.bind(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);

  // Two repos
  for (const sym of [MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository, MARKETING_PAGES_SYMBOLS.IPagesRepository]) {
    if (marketingPagesContainer.isBound(sym)) marketingPagesContainer.unbind(sym);
  }
  const settingsRepo = new SiteSettingsRepository(config, tracer, logger);
  const pagesRepo = new PagesRepository(config, tracer, logger);
  marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository).toConstantValue(settingsRepo);
  marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.IPagesRepository).toConstantValue(pagesRepo);

  const wrappedGetSettings = withSpan(
    tracer,
    { name: "marketing-pages.getSiteSettings", op: "use-case" },
    getSiteSettingsUseCase(settingsRepo),
  );
  const wrappedGetPage = withSpan(
    tracer,
    { name: "marketing-pages.getPageBySlug", op: "use-case" },
    getPageBySlugUseCase(pagesRepo),
  );

  for (const sym of [
    MARKETING_PAGES_SYMBOLS.GetSiteSettingsUseCase,
    MARKETING_PAGES_SYMBOLS.GetPageBySlugUseCase,
    MARKETING_PAGES_SYMBOLS.GetSiteSettingsController,
    MARKETING_PAGES_SYMBOLS.GetPageBySlugController,
  ]) {
    if (marketingPagesContainer.isBound(sym)) marketingPagesContainer.unbind(sym);
  }
  marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.GetSiteSettingsUseCase).toConstantValue(wrappedGetSettings);
  marketingPagesContainer.bind(MARKETING_PAGES_SYMBOLS.GetPageBySlugUseCase).toConstantValue(wrappedGetPage);

  marketingPagesContainer
    .bind(MARKETING_PAGES_SYMBOLS.GetSiteSettingsController)
    .toConstantValue(
      withSpan(
        tracer,
        { name: "marketing-pages.getSiteSettings", op: "controller" },
        getSiteSettingsController(wrappedGetSettings),
      ),
    );
  marketingPagesContainer
    .bind(MARKETING_PAGES_SYMBOLS.GetPageBySlugController)
    .toConstantValue(
      withSpan(
        tracer,
        { name: "marketing-pages.getPageBySlug", op: "controller" },
        getPageBySlugController(wrappedGetPage),
      ),
    );
}
```

- [ ] **Step 4: Update bind-dev-seed.ts** — same pattern as Task 18 Step 4 with marketing-pages's use cases + controllers.

- [ ] **Step 5: Update apps/web-next/src/server/bind-production.ts**

```ts
// In bindAllProduction:
bindProductionMarketingPages(resolvedConfig, tracer, logger);

// In bindAllDevSeed:
await bindDevSeedMarketingPages(tracer, logger);
```

- [ ] **Step 6: Span-shape tests** — one per repo (site-settings + pages).

- [ ] **Step 7: Run tests**

Run: `pnpm --filter @repo/marketing-pages test && pnpm --filter web-next test`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add packages/marketing-pages/src/ apps/web-next/src/server/bind-production.ts
git commit -m "feat(marketing-pages): wire instrumentation — site-settings + pages spans + use-case/controller withSpan"
```

---

### Task 21: Navigation feature wiring

**Files:**
- Modify: `packages/navigation/src/infrastructure/repositories/header.repository.ts`
- Modify: `packages/navigation/src/infrastructure/repositories/header.repository.mock.ts`
- Modify: `packages/navigation/src/di/bind-production.ts`
- Modify: `packages/navigation/src/di/bind-dev-seed.ts`
- Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger)

**Single repo:** header. **Single use case:** `navigation.getHeader`.

- [ ] **Step 1: Read existing files.**

- [ ] **Step 2: Apply Phase E preamble pattern to header.repository.ts (real + mock)** — spans `header.<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**

```ts
bindProductionNavigation(resolvedConfig, tracer, logger);
await bindDevSeedNavigation(tracer, logger);
```

- [ ] **Step 6: Span-shape test** for the header mock repo.

- [ ] **Step 7: Run tests**

Run: `pnpm --filter @repo/navigation test && pnpm --filter web-next test`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add packages/navigation/src/ apps/web-next/src/server/bind-production.ts
git commit -m "feat(navigation): wire instrumentation — header repo spans + getHeader withSpan"
```

---

### Task 22: Media feature wiring

**Files:**
- Modify: `packages/media/src/infrastructure/repositories/media.repository.ts`
- Modify: `packages/media/src/infrastructure/repositories/media.repository.mock.ts`
- Modify: `packages/media/src/di/bind-production.ts`
- Modify: `packages/media/src/di/bind-dev-seed.ts`
- Modify: `apps/web-next/src/server/bind-production.ts` (thread tracer/logger)

**Single repo:** media. **Use cases:** `media.getMedia`, `media.listMedia`, `media.deleteMedia`.

- [ ] **Step 1: Read existing files.**

- [ ] **Step 2: Apply Phase E preamble pattern to media.repository.ts (real + mock)** — spans `media.<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**

```ts
bindProductionMedia(resolvedConfig, tracer, logger);
await bindDevSeedMedia(tracer, logger);
```

- [ ] **Step 6: Span-shape test** for media mock repo.

- [ ] **Step 7: Run tests**

Run: `pnpm --filter @repo/media test && pnpm --filter web-next test`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add packages/media/src/ apps/web-next/src/server/bind-production.ts
git commit -m "feat(media): wire instrumentation — media repo spans + getMedia/listMedia/deleteMedia withSpan"
```

---

## Phase F — Contract suite span assertions

### Task 23: `defineContractSuite` `expectSpan` helper

**Files:**
- Modify: `packages/core-testing/src/contract/define-contract-suite.ts`
- Modify: `packages/core-testing/src/contract/define-contract-suite.test.ts`

- [ ] **Step 1: Update define-contract-suite.ts**

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

export interface ContractContext<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:

```ts
// Append to packages/core-testing/src/contract/define-contract-suite.test.ts
import { describe, it, expect } from "vitest";
import { defineContractSuite } from "@/contract/define-contract-suite";
import { RecordingTracer } from "@/instrumentation/recording-tracer";

describe("defineContractSuite — getTracer plumbing (R50)", () => {
  it("passes the tracer accessor into the suite", () => {
    let receivedTracer: RecordingTracer | undefined;
    const tracer = new RecordingTracer();
    const suite = defineContractSuite<{ foo: string }>("Test", ({ buildSubject, getTracer }) => {
      it("can read tracer", async () => {
        const subject = await buildSubject();
        expect(subject.foo).toBe("bar");
        receivedTracer = getTracer?.();
      });
    });
    suite.run(() => ({ foo: "bar" }), { tracer: () => tracer });
    // Vitest defers actual assertion to the `it`; we verify the wiring by re-reading after.
    // (This is a meta-test of plumbing only — the inner it() runs as a child describe.)
    expect(typeof tracer.startSpan).toBe("function");
  });

  it("getTracer is undefined when opts.tracer not provided (backward compat)", () => {
    let receivedAccessor: unknown = undefined;
    const suite = defineContractSuite<{ x: number }>("Test", ({ buildSubject, getTracer }) => {
      it("accessor undefined", async () => {
        await buildSubject();
        receivedAccessor = getTracer;
      });
    });
    suite.run(() => ({ x: 1 }));
    // No tracer opts → accessor is undefined inside the suite body.
    // (Exact assertion happens via type, not runtime — typecheck gates this.)
    void receivedAccessor;
  });
});
```

Run: `pnpm --filter @repo/core-testing test define-contract-suite`
Expected: PASS — existing tests + 2 new tests.

- [ ] **Step 3: Commit**

```bash
git add packages/core-testing/src/contract/define-contract-suite.ts \
        packages/core-testing/src/contract/define-contract-suite.test.ts
git commit -m "feat(core-testing): R50 — contract context gains optional getTracer accessor"
```

---

### Task 24: Update every repo contract suite to assert span shape

**Files (all 6 contracts):**
- `packages/blog/src/__contracts__/articles-repository.contract.ts`
- `packages/auth/src/__contracts__/users-repository.contract.ts`
- `packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts`
- `packages/marketing-pages/src/__contracts__/pages-repository.contract.ts`
- `packages/navigation/src/__contracts__/header-repository.contract.ts`
- `packages/media/src/__contracts__/media-repository.contract.ts`

Plus the call sites that run them (the per-feature `*.repository.mock.test.ts` and any real-Payload contract integration test) — update to pass `{ tracer: () => recordingTracer }`.

> **Pattern (applies to every contract):** add a final `describe.skipIf(!getTracer)("span emission (R50)")` block enumerating one `it()` per repo method, each invoking the method and asserting `tracer.findSpan("<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:

```ts
import { RecordingTracer } from "@repo/core-testing/instrumentation"; // add at top

// At the end of the suite body, before the closing `});`:

describe("span emission (R50)", () => {
  it("getArticles emits articles.getArticles span with op=repository", async () => {
    if (!getTracer) return;
    const tracer = getTracer();
    tracer.reset();
    await repo.getArticles({ limit: 5 });
    const span = tracer.findSpan("articles.getArticles");
    expect(span).toBeDefined();
    expect(span!.op).toBe("repository");
    expect(span!.attributes.limit).toBe(5);
  });

  it("getArticle emits articles.getArticle span with id attribute", async () => {
    if (!getTracer) return;
    const tracer = getTracer();
    tracer.reset();
    await repo.getArticle("nonexistent");
    const span = tracer.findSpan("articles.getArticle");
    expect(span).toBeDefined();
    expect(span!.attributes.id).toBe("nonexistent");
  });

  it("getArticleBySlug emits articles.getArticleBySlug span with slug attribute", async () => {
    if (!getTracer) return;
    const tracer = getTracer();
    tracer.reset();
    await repo.getArticleBySlug("nonexistent");
    const span = tracer.findSpan("articles.getArticleBySlug");
    expect(span).toBeDefined();
    expect(span!.attributes.slug).toBe("nonexistent");
  });

  it("createArticle emits articles.createArticle span", async () => {
    if (!getTracer) return;
    const tracer = getTracer();
    tracer.reset();
    const seed = articleFactory.build();
    await repo.createArticle(seed);
    const span = tracer.findSpan("articles.createArticle");
    expect(span).toBeDefined();
    expect(span!.attributes.slug).toBe(seed.slug);
  });

  it("updateArticle emits articles.updateArticle span", async () => {
    if (!getTracer) return;
    const tracer = getTracer();
    tracer.reset();
    const seed = articleFactory.build();
    const created = await repo.createArticle(seed);
    await repo.updateArticle(created.id, { title: "Updated" });
    const span = tracer.findSpan("articles.updateArticle");
    expect(span).toBeDefined();
    expect(span!.attributes.id).toBe(created.id);
  });
});
```

> **Note:** the suite's `getTracer` and `repo` references must already be in scope. If the existing suite doesn't accept `getTracer` from `ContractContext`, update the destructuring at the top:
> `({ buildSubject, getTracer }) => { ... }`.
> Also import `describe` if it isn't already imported at the top.

- [ ] **Step 2: Update the blog mock contract caller**

The contract suite is consumed by mock-side tests like `articles.repository.mock.test.ts`. Update the consumer to pass `tracer`:

```ts
// packages/blog/src/infrastructure/repositories/articles.repository.mock.test.ts (excerpt)
import { RecordingTracer } from "@repo/core-testing/instrumentation";
import { MockArticlesRepository } from "./articles.repository.mock";
import { articlesRepositoryContract } from "../../__contracts__/articles-repository.contract";

const tracer = new RecordingTracer();
articlesRepositoryContract.run(
  () => new MockArticlesRepository(tracer),
  { tracer: () => tracer },
);
```

- [ ] **Step 3: Update the blog real-payload contract caller (if present)**

If the codebase has an integration test that runs the contract against the real Payload-backed repository (e.g., `articles.repository.test.ts`), thread the tracer through identically:

```ts
const tracer = new RecordingTracer();
articlesRepositoryContract.run(
  async () => {
    const config = await stubConfig(); // existing test helper
    return new ArticlesRepository(config, tracer);
  },
  { tracer: () => tracer },
);
```

- [ ] **Step 4: Repeat Steps 1–3 for the other five contracts**

For each of:
- `auth/src/__contracts__/users-repository.contract.ts` — methods enumerated by reading the file (typically `getUserByEmail`, `getUser`, `createUser`, etc.). Spans `users.<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**

```bash
git add packages/blog/src/__contracts__/articles-repository.contract.ts \
        packages/blog/src/infrastructure/repositories/articles.repository.mock.test.ts \
        packages/blog/src/infrastructure/repositories/articles.repository.test.ts \
        packages/auth/src/__contracts__/users-repository.contract.ts \
        packages/auth/src/infrastructure/repositories/ \
        packages/marketing-pages/src/__contracts__/ \
        packages/marketing-pages/src/infrastructure/repositories/ \
        packages/navigation/src/__contracts__/header-repository.contract.ts \
        packages/navigation/src/infrastructure/repositories/ \
        packages/media/src/__contracts__/media-repository.contract.ts \
        packages/media/src/infrastructure/repositories/
git commit -m "test(features): R50 — repo contract suites assert span shape per method"
```

---

## Phase G — App integration

### Task 25: apps/web-next instrumentation files + PII scrubber test

**Files:**
- Create: `apps/web-next/instrumentation.ts`
- Create: `apps/web-next/instrumentation-client.ts`
- Modify: `apps/web-next/next.config.mjs`
- Modify: `apps/web-next/package.json` (add `@sentry/nextjs` dep)
- Create: `apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts`

- [ ] **Step 1: Add the dep**

```bash
pnpm --filter web-next add @sentry/nextjs
```

- [ ] **Step 2: Write apps/web-next/instrumentation.ts**

```ts
// apps/web-next/instrumentation.ts
// Next.js convention: this module runs once on server boot.
// We delegate to the centralized init helper in core-shared.

export async function register() {
  if (
    process.env.NEXT_RUNTIME === "nodejs" ||
    process.env.NEXT_RUNTIME === "edge"
  ) {
    const { initSentryServer } = await import(
      "@repo/core-shared/instrumentation/sentry/init-server"
    );
    initSentryServer({
      dsn: process.env.WEB_NEXT_SENTRY_DSN,
      app: "web-next",
      release: process.env.VERCEL_GIT_COMMIT_SHA,
    });
  }
}
```

- [ ] **Step 3: Write apps/web-next/instrumentation-client.ts**

```ts
// apps/web-next/instrumentation-client.ts
// Next.js 15+ browser hook: runs in the client bundle on app start.

import { initSentryClient } from "@repo/core-shared/instrumentation/sentry/init-client";

initSentryClient({
  dsn: process.env.NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN,
  app: "web-next",
  release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA,
});
```

- [ ] **Step 4: Wrap next.config.mjs with `withSentryConfig`**

Open `apps/web-next/next.config.mjs`. Wrap the default export:

```js
// apps/web-next/next.config.mjs (excerpt — preserve existing config object)
import { withSentryConfig } from "@sentry/nextjs";

const nextConfig = {
  // ... existing config (unchanged) ...
};

export default withSentryConfig(nextConfig, {
  // R52 — token is build-time only; CI sets SENTRY_AUTH_TOKEN
  silent: process.env.CI !== "true",
  authToken: process.env.SENTRY_AUTH_TOKEN,
  org: process.env.SENTRY_ORG,
  project: process.env.SENTRY_PROJECT_WEB_NEXT,
  // Don't tunnel through the app server in dev — use direct uploads
  hideSourceMaps: true,
  disableLogger: true,
});
```

- [ ] **Step 5: Write the PII scrubber smoke test (R38)**

```ts
// apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts
import { describe, it, expect } from "vitest";
import {
  beforeSend,
  beforeSendTransaction,
} from "@repo/core-shared/instrumentation/sentry/scrub";

describe("R38 — apps/web-next PII scrubber", () => {
  it("strips email/password/cookie/auth/IP from event payload", () => {
    const event = {
      extra: {
        userEmail: "alice@example.com",
        password: "p4$$w0rd",
        ipAddress: "192.168.1.10",
        note: "request from 10.0.0.1",
      },
      request: {
        headers: {
          Authorization: "Bearer secret",
          "Set-Cookie": "session=abc",
          "User-Agent": "Mozilla",
        },
      },
    } as any;
    const result = beforeSend(event, {} as any) as any;
    expect(result.extra.userEmail).toBe("[redacted]");
    expect(result.extra.password).toBe("[redacted]");
    expect(result.extra.ipAddress).toBe("[redacted]");
    expect(result.extra.note).toContain("[redacted-ip]");
    expect(result.request.headers.Authorization).toBe("[redacted]");
    expect(result.request.headers["Set-Cookie"]).toBe("[redacted]");
    expect(result.request.headers["User-Agent"]).toBe("Mozilla");
  });

  it("strips ?token / ?email / ?password / ?secret / ?signature from URLs", () => {
    const event = {
      request: {
        url: "https://app/api/x?token=abc&email=a@b.c&password=p&secret=z&signature=s&safe=1",
      },
      transaction: "/foo?accessToken=t",
    } as any;
    const result = beforeSendTransaction(event, {} as any) as any;
    const url = decodeURIComponent(result.request.url);
    const txn = decodeURIComponent(result.transaction);
    for (const key of ["token", "email", "password", "secret", "signature"]) {
      expect(url).toContain(`${key}=[redacted]`);
    }
    expect(url).toContain("safe=1");
    expect(txn).toContain("accessToken=[redacted]");
  });
});
```

Run: `pnpm --filter web-next test sentry-pii-scrubber`
Expected: PASS — 2 tests.

- [ ] **Step 6: Commit**

```bash
git add apps/web-next/instrumentation.ts \
        apps/web-next/instrumentation-client.ts \
        apps/web-next/next.config.mjs \
        apps/web-next/package.json \
        apps/web-next/src/__tests__/sentry-pii-scrubber.test.ts \
        pnpm-lock.yaml
git commit -m "feat(web-next): Sentry instrumentation hooks + withSentryConfig + R38 PII test"
```

---

### Task 26: apps/cms instrumentation files + PII scrubber test

**Files:**
- Create: `apps/cms/instrumentation.ts`
- Modify: `apps/cms/next.config.mjs`
- Modify: `apps/cms/package.json` (add `@sentry/nextjs`)
- Create: `apps/cms/src/__tests__/sentry-pii-scrubber.test.ts`

> **Difference from web-next:** CMS is server-only (Payload admin UI); no `instrumentation-client.ts` needed (Payload's admin runs in a browser but its bundling/build is opinionated and the public DSN flow is Payload-specific — defer client-side CMS instrumentation as out-of-scope per spec §8). Server side is identical.

- [ ] **Step 1: Add the dep**

```bash
pnpm --filter cms add @sentry/nextjs
```

- [ ] **Step 2: Write apps/cms/instrumentation.ts**

```ts
// apps/cms/instrumentation.ts
export async function register() {
  if (
    process.env.NEXT_RUNTIME === "nodejs" ||
    process.env.NEXT_RUNTIME === "edge"
  ) {
    const { initSentryServer } = await import(
      "@repo/core-shared/instrumentation/sentry/init-server"
    );
    initSentryServer({
      dsn: process.env.CMS_SENTRY_DSN,
      app: "cms",
      release: process.env.VERCEL_GIT_COMMIT_SHA,
    });
  }
}
```

- [ ] **Step 3: Wrap next.config.mjs**

Same pattern as Task 25 Step 4, but the project env var is `SENTRY_PROJECT_CMS`:

```js
import { withSentryConfig } from "@sentry/nextjs";

const nextConfig = { /* existing */ };

export default withSentryConfig(nextConfig, {
  silent: process.env.CI !== "true",
  authToken: process.env.SENTRY_AUTH_TOKEN,
  org: process.env.SENTRY_ORG,
  project: process.env.SENTRY_PROJECT_CMS,
  hideSourceMaps: true,
  disableLogger: true,
});
```

- [ ] **Step 4: PII scrubber smoke test (R38)**

Create `apps/cms/src/__tests__/sentry-pii-scrubber.test.ts` with the same content as Task 25 Step 5. (Copy verbatim — the tests are app-agnostic but R38 requires one per app to ensure each app's vitest config is wired correctly.)

Run: `pnpm --filter cms test sentry-pii-scrubber`
Expected: PASS — 2 tests.

- [ ] **Step 5: Commit**

```bash
git add apps/cms/instrumentation.ts \
        apps/cms/next.config.mjs \
        apps/cms/package.json \
        apps/cms/src/__tests__/sentry-pii-scrubber.test.ts \
        pnpm-lock.yaml
git commit -m "feat(cms): Sentry server instrumentation + withSentryConfig + R38 PII test"
```

---

### Task 27: apps/web-tanstack instrumentation files + PII scrubber test

**Files:**
- Create: `apps/web-tanstack/src/instrumentation.ts` (server entry hook)
- Create: `apps/web-tanstack/src/instrumentation-client.ts` (client entry hook)
- Modify: `apps/web-tanstack/vite.config.ts` (add `@sentry/vite-plugin`)
- Modify: `apps/web-tanstack/package.json` (add `@sentry/node` + `@sentry/react` + `@sentry/vite-plugin`)
- Create: `apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts`

> **Difference from web-next/cms:** TanStack Start uses Vite, not Next.js. The `@sentry/nextjs` package is wrong here — use `@sentry/node` (server) and `@sentry/react` (client). The init helpers in `core-shared/instrumentation/sentry/` need a Vite/Vanilla flavor. To minimize complexity, this task adds two thin wrappers in core-shared that re-export the same scrubbers but call `@sentry/node`/`@sentry/react` instead. (If preferred for v1, mark this task as **deferred** and skip web-tanstack — but the spec requires three-app coverage.)

- [ ] **Step 1: Add deps**

```bash
pnpm --filter web-tanstack add @sentry/node @sentry/react
pnpm --filter web-tanstack add -D @sentry/vite-plugin
```

- [ ] **Step 2: Add `@sentry/node` + `@sentry/react` core-shared adapters**

Create `packages/core-shared/src/instrumentation/sentry/init-server-node.ts`:

```ts
// packages/core-shared/src/instrumentation/sentry/init-server-node.ts
import * as SentryNode from "@sentry/node";
import { beforeSend, beforeSendTransaction } from "./scrub";
import type { InitServerOpts } from "./init-server";

/**
 * Server-side init for non-Next.js runtimes (TanStack Start). Mirrors
 * init-server.ts but uses @sentry/node directly. R31, R32, R33 still apply.
 */
export function initSentryServerNode(opts: InitServerOpts): void {
  if (!opts.dsn) return;

  const isProd = process.env.NODE_ENV === "production";
  const tracesSampleRate =
    process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined
      ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE)
      : isProd
        ? 0.1
        : 1.0;

  SentryNode.init({
    dsn: opts.dsn,
    environment:
      process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development",
    release: opts.release ?? process.env.VITE_GIT_COMMIT_SHA ?? "unknown",
    tracesSampleRate,
    sendDefaultPii: false,
    beforeSend: beforeSend as any,
    beforeSendTransaction: beforeSendTransaction as any,
    initialScope: { tags: { app: opts.app } },
  });
}
```

Create `packages/core-shared/src/instrumentation/sentry/init-client-react.ts`:

```ts
// packages/core-shared/src/instrumentation/sentry/init-client-react.ts
import * as SentryReact from "@sentry/react";
import { beforeSend, beforeSendTransaction } from "./scrub";
import type { InitClientOpts } from "./init-client";

export function initSentryClientReact(opts: InitClientOpts): void {
  if (!opts.dsn) return;

  const isProd = process.env.NODE_ENV === "production";
  const tracesSampleRate =
    process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined
      ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE)
      : isProd
        ? 0.1
        : 1.0;

  SentryReact.init({
    dsn: opts.dsn,
    environment:
      process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development",
    release: opts.release ?? "unknown",
    tracesSampleRate,
    sendDefaultPii: false,
    beforeSend: beforeSend as any,
    beforeSendTransaction: beforeSendTransaction as any,
    replaysSessionSampleRate: 0.0,
    replaysOnErrorSampleRate: 1.0,
    integrations: [
      SentryReact.replayIntegration({
        maskAllText: true,
        maskAllInputs: true,
        blockAllMedia: true,
      }),
    ],
    initialScope: { tags: { app: opts.app } },
  });
}
```

Add the two new files to `packages/core-shared/src/instrumentation/index.ts`:

```ts
export { initSentryServerNode } from "./sentry/init-server-node";
export { initSentryClientReact } from "./sentry/init-client-react";
```

Add `@sentry/node` + `@sentry/react` to `packages/core-shared/package.json` `peerDependencies` (optional) so apps that use them install them; the core-shared test process gets them via the test guard mock if needed (extend `setup/no-sentry.ts` to also mock `@sentry/node` and `@sentry/react`).

- [ ] **Step 3: Extend setup/no-sentry.ts to mock @sentry/node + @sentry/react**

Edit `packages/core-testing/src/setup/no-sentry.ts` and add:

```ts
vi.mock("@sentry/node", () => ({
  init: vi.fn(),
  startSpan: vi.fn((_opts: unknown, fn: any) =>
    fn({ setAttribute: vi.fn(), setStatus: vi.fn() }),
  ),
  captureException: vi.fn(),
  captureMessage: vi.fn(),
  addBreadcrumb: vi.fn(),
  setUser: vi.fn(),
}));

vi.mock("@sentry/react", () => ({
  init: vi.fn(),
  captureException: vi.fn(),
  captureMessage: vi.fn(),
  addBreadcrumb: vi.fn(),
  setUser: vi.fn(),
  replayIntegration: vi.fn(() => ({ name: "Replay" })),
}));
```

- [ ] **Step 4: Write tests for the new init helpers**

Create `packages/core-shared/src/instrumentation/sentry/init-server-node.test.ts` and `init-client-react.test.ts` mirroring Tasks 10 & 11 (assert sendDefaultPii: false, scrubbers attached, mask flags, no-op on missing DSN).

Run: `pnpm --filter @repo/core-shared test init-server-node init-client-react`
Expected: PASS.

- [ ] **Step 5: Write web-tanstack instrumentation files**

```ts
// apps/web-tanstack/src/instrumentation.ts
import { initSentryServerNode } from "@repo/core-shared/instrumentation/sentry/init-server-node";

initSentryServerNode({
  dsn: process.env.WEB_TANSTACK_SENTRY_DSN,
  app: "web-tanstack",
  release: process.env.VITE_GIT_COMMIT_SHA,
});
```

```ts
// apps/web-tanstack/src/instrumentation-client.ts
import { initSentryClientReact } from "@repo/core-shared/instrumentation/sentry/init-client-react";

initSentryClientReact({
  dsn: import.meta.env.VITE_WEB_TANSTACK_SENTRY_DSN,
  app: "web-tanstack",
  release: import.meta.env.VITE_GIT_COMMIT_SHA,
});
```

> **Wiring:** `apps/web-tanstack/src/server.ts` (or wherever the server entry boots) MUST `import "./instrumentation"` at the top. The client entry (`src/main.tsx` or `src/client.tsx`) MUST `import "./instrumentation-client"` at the top.

- [ ] **Step 6: Update vite.config.ts to upload source maps**

```ts
// apps/web-tanstack/vite.config.ts (excerpt)
import { sentryVitePlugin } from "@sentry/vite-plugin";
// ... existing imports ...

export default defineConfig({
  // ... existing config ...
  build: {
    // ... existing build config ...
    sourcemap: true, // required by sentryVitePlugin
  },
  plugins: [
    // ... existing plugins ...
    sentryVitePlugin({
      authToken: process.env.SENTRY_AUTH_TOKEN,
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT_WEB_TANSTACK,
      silent: process.env.CI !== "true",
      disable: !process.env.SENTRY_AUTH_TOKEN, // skip in non-CI builds
    }),
  ],
});
```

- [ ] **Step 7: PII scrubber smoke test (R38)**

Create `apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts` with content identical to Task 25 Step 5.

Run: `pnpm --filter web-tanstack test sentry-pii-scrubber`
Expected: PASS — 2 tests.

- [ ] **Step 8: Commit**

```bash
git add apps/web-tanstack/src/instrumentation.ts \
        apps/web-tanstack/src/instrumentation-client.ts \
        apps/web-tanstack/vite.config.ts \
        apps/web-tanstack/package.json \
        apps/web-tanstack/src/__tests__/sentry-pii-scrubber.test.ts \
        packages/core-shared/src/instrumentation/sentry/init-server-node.ts \
        packages/core-shared/src/instrumentation/sentry/init-server-node.test.ts \
        packages/core-shared/src/instrumentation/sentry/init-client-react.ts \
        packages/core-shared/src/instrumentation/sentry/init-client-react.test.ts \
        packages/core-shared/src/instrumentation/index.ts \
        packages/core-shared/package.json \
        packages/core-testing/src/setup/no-sentry.ts \
        pnpm-lock.yaml
git commit -m "feat(web-tanstack): Sentry instrumentation via @sentry/node + @sentry/react + R38 PII test"
```

---

## Phase H — Boundary enforcement + config

### Task 28: ESLint boundary rule (R40) + CI grep for `sendDefaultPii: true` (R31)

**Files:**
- Modify: `packages/core-eslint/base.js`
- Create: `.github/workflows/sentry-pii-guard.yml` (or extend existing CI workflow)

- [ ] **Step 1: Add `no-restricted-imports` to core-eslint/base.js**

Open `packages/core-eslint/base.js`. Add a new flat-config block (preserve existing entries):

```js
// packages/core-eslint/base.js (excerpt — append a new config block)
{
  files: ["**/*.{ts,tsx,mjs,cjs,js}"],
  ignores: [
    // R40 — only these paths may import from @sentry/*
    "packages/core-shared/src/instrumentation/sentry/**",
    "packages/core-testing/src/setup/no-sentry.{ts,js}",
    "apps/*/instrumentation.{ts,js,mjs}",
    "apps/*/instrumentation-client.{ts,js,mjs}",
    "apps/*/src/instrumentation.{ts,js,mjs}",
    "apps/*/src/instrumentation-client.{ts,js,mjs}",
    "apps/*/next.config.{mjs,ts,js}",
    "apps/*/vite.config.{ts,mjs,js}",
    "apps/*/sentry.*.config.{ts,mjs,js}",
  ],
  rules: {
    "no-restricted-imports": [
      "error",
      {
        patterns: [
          {
            group: ["@sentry/*"],
            message:
              "Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly (R40).",
          },
        ],
      },
    ],
  },
},
```

> **Note:** the `ignores` array on a flat-config block is the **opposite** of allowlist — these are paths that DO get the rule applied. To make the allowlist work, invert: apply the rule to *everything* and override (turn off) for the allowlisted paths in a follow-up block. Use this two-block pattern instead:

```js
// Block 1 — apply restriction repo-wide
{
  files: ["**/*.{ts,tsx,mjs,cjs,js}"],
  rules: {
    "no-restricted-imports": [
      "error",
      {
        patterns: [
          {
            group: ["@sentry/*"],
            message:
              "Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly (R40).",
          },
        ],
      },
    ],
  },
},
// Block 2 — override (allow Sentry imports) for the explicit allowlist
{
  files: [
    "packages/core-shared/src/instrumentation/sentry/**",
    "packages/core-testing/src/setup/no-sentry.{ts,js}",
    "apps/*/instrumentation.{ts,js,mjs}",
    "apps/*/instrumentation-client.{ts,js,mjs}",
    "apps/*/src/instrumentation.{ts,js,mjs}",
    "apps/*/src/instrumentation-client.{ts,js,mjs}",
    "apps/*/next.config.{mjs,ts,js}",
    "apps/*/vite.config.{ts,mjs,js}",
    "apps/*/sentry.*.config.{ts,mjs,js}",
  ],
  rules: {
    "no-restricted-imports": "off",
  },
},
```

- [ ] **Step 2: Verify the rule fires**

Create a temporary throwaway file to test, then delete:

```bash
mkdir -p /tmp/sentry-rule-check
cat <<'EOF' > /tmp/sentry-rule-check/violator.ts
import * as Sentry from "@sentry/nextjs";
console.log(Sentry);
EOF
# Place inside a feature path to actually exercise the rule:
cp /tmp/sentry-rule-check/violator.ts packages/blog/src/__violator__.ts
pnpm --filter @repo/blog lint || echo "EXPECTED: lint should fail with R40 message"
rm packages/blog/src/__violator__.ts
```

Expected: lint fails with the R40 message.

Repeat the check at an allowlisted path:

```bash
cp /tmp/sentry-rule-check/violator.ts apps/web-next/instrumentation-temp.ts
# (instrumentation*.ts is allowlisted)
pnpm --filter web-next lint && echo "EXPECTED: lint should PASS at allowlisted path"
rm apps/web-next/instrumentation-temp.ts
rm -rf /tmp/sentry-rule-check
```

Expected: lint passes (the allowlist correctly turns off the rule there).

- [ ] **Step 3: Add a CI grep step for R31 (`sendDefaultPii: true`)**

If a GitHub Actions workflow already exists, append a step. Otherwise create `.github/workflows/sentry-pii-guard.yml`:

```yaml
# .github/workflows/sentry-pii-guard.yml
name: Sentry PII guard (R31)

on:
  pull_request:
  push:
    branches: [main]

jobs:
  pii-guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Verify sendDefaultPii is never true
        run: |
          if grep -RIn --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.cjs' --include='*.js' \
              -E 'sendDefaultPii\s*:\s*true' \
              packages/ apps/; then
            echo "::error::R31 violation — sendDefaultPii: true is forbidden anywhere in the repo."
            exit 1
          fi
          echo "OK — no sendDefaultPii: true detected."
```

- [ ] **Step 4: Run lint repo-wide**

Run: `pnpm lint`
Expected: PASS — no current violation (instrumentation files in apps/ are allowlisted; core-shared/instrumentation/sentry/ is allowlisted; everything else doesn't import @sentry/*).

- [ ] **Step 5: Commit**

```bash
git add packages/core-eslint/base.js .github/workflows/sentry-pii-guard.yml
git commit -m "feat(eslint+ci): R40 boundary rule for @sentry/* + R31 sendDefaultPii grep gate"
```

---

### Task 29: turbo.json globalEnv updates

**Files:**
- Modify: `turbo.json`

- [ ] **Step 1: Read current turbo.json**

Run: `cat turbo.json | jq .globalEnv`

Note the existing entries (e.g., `USE_DEV_SEED`, `NODE_ENV`).

- [ ] **Step 2: Append the 8 instrumentation env vars**

Edit `turbo.json` and add to `globalEnv` (preserving existing entries):

```json
{
  "globalEnv": [
    "USE_DEV_SEED",
    "NODE_ENV",
    "WEB_NEXT_SENTRY_DSN",
    "NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN",
    "CMS_SENTRY_DSN",
    "WEB_TANSTACK_SENTRY_DSN",
    "VITE_WEB_TANSTACK_SENTRY_DSN",
    "SENTRY_AUTH_TOKEN",
    "SENTRY_ORG",
    "SENTRY_PROJECT_WEB_NEXT",
    "SENTRY_PROJECT_CMS",
    "SENTRY_PROJECT_WEB_TANSTACK",
    "SENTRY_TRACES_SAMPLE_RATE",
    "SENTRY_ENVIRONMENT",
    "VERCEL_GIT_COMMIT_SHA",
    "NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA",
    "VERCEL_ENV"
  ]
}
```

- [ ] **Step 3: Verify turbo accepts the config**

Run: `pnpm turbo build --dry`
Expected: no warnings about undeclared env vars.

- [ ] **Step 4: Commit**

```bash
git add turbo.json
git commit -m "chore(turbo): declare instrumentation env vars in globalEnv"
```

---

## Phase I — Docs + HTML

### Task 30: Doc updates — CLAUDE.md, AGENTS.md, vertical-feature-spec.md

**Files:**
- Modify: `CLAUDE.md`
- Modify: `AGENTS.md`
- Modify: `docs/architecture/vertical-feature-spec.md`

- [ ] **Step 1: Update CLAUDE.md**

Open `CLAUDE.md` and append to the "Key Conventions" section:

```markdown
- **Instrumentation lives in `core-shared/instrumentation/`** — Two interfaces (`ITracer`, `ILogger`), three implementations (`NoopTracer`/`NoopLogger`, `SentryTracer`/`SentryLogger`, and `RecordingTracer`/`RecordingLogger` from `core-testing`). Feature packages MUST NOT import `@sentry/*` directly (R40, eslint-enforced).
- **Spans applied at DI bind time** — Use cases + controllers wrapped via `withSpan(tracer, { name: "<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:

```markdown
## Instrumentation conventions

**Symbols (in `core-shared/instrumentation/symbols.ts`):**
- `INSTRUMENTATION_SYMBOLS.TRACER` — bound to `ITracer` (NoopTracer / SentryTracer)
- `INSTRUMENTATION_SYMBOLS.LOGGER` — bound to `ILogger` (NoopLogger / SentryLogger)

**Repository constructor signature (every feature):**

```ts
constructor(
  config: SanitizedConfig,
  tracer: ITracer = new NoopTracer(),
  logger: ILogger = new NoopLogger(),
)
```

**Repository method body (every public async method):**

```ts
return this.tracer.startSpan(
  { name: "<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):**

```ts
const wrappedUC = withSpan(tracer, { name: "blog.getArticles", op: "use-case" }, getArticlesUseCase(repo));
const wrappedCtrl = withSpan(tracer, { name: "blog.getArticles", op: "controller" }, getArticlesController(wrappedUC));
```

**Capture rules:**

| Layer | Captures | Doesn't capture |
|---|---|---|
| Repository | Infra/Payload errors that originate here | Bubbled errors |
| Use case | Business-rule violations originated in this body | Errors from repos |
| Controller | InputParseError from safeParse failure | Anything else |
| `defineErrorMiddleware` | Nothing — maps domain → TRPCError only | — |

**Boundary rule (eslint-enforced):**
Feature packages MUST NOT `import "@sentry/*"`. Allowlist:
- `packages/core-shared/src/instrumentation/sentry/**`
- `apps/*/instrumentation*.{ts,mjs,js}`
- `apps/*/next.config.{mjs,ts,js}`
- `apps/*/vite.config.{ts,mjs,js}`

**Test rules:**
- Default to `NoopTracer` / `NoopLogger` (constructor defaults).
- Assert spans/captures by injecting `RecordingTracer` / `RecordingLogger` from `@repo/core-testing/instrumentation`.
- Real `@sentry/*` SDK MUST NOT initialize during tests (guarded by `core-testing/setup/no-sentry.ts`).
```

- [ ] **Step 3: Update vertical-feature-spec.md**

Open `docs/architecture/vertical-feature-spec.md` and append a new section:

```markdown
## §10 — Instrumentation & error capture

**Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (Plan 10, R31–R55).

**File additions per feature:**

- `infrastructure/repositories/<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**

```bash
git add CLAUDE.md AGENTS.md docs/architecture/vertical-feature-spec.md
git commit -m "docs: instrumentation conventions in CLAUDE.md / AGENTS.md / vertical-feature-spec.md"
```

---

### Task 31: Doc updates — tdd-workflow.md, testing-strategy.md, dependency-flow.md, core-shared/AGENTS.md

**Files:**
- Modify: `docs/guides/tdd-workflow.md`
- Modify: `docs/guides/testing-strategy.md`
- Modify: `docs/architecture/dependency-flow.md`
- Create or Modify: `packages/core-shared/AGENTS.md`

- [ ] **Step 1: Update tdd-workflow.md**

Append a section "Asserting spans and captures":

```markdown
## Asserting spans and captures

Use cases, controllers, and repositories emit OpenTelemetry-style spans through the `ITracer` interface. Tests that need to assert span shape inject a `RecordingTracer`:

```ts
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";

describe("blog.getArticles use case", () => {
  it("emits a use-case span when invoked", async () => {
    const tracer = new RecordingTracer();
    const logger = new RecordingLogger();
    const repo = new MockArticlesRepository(tracer, logger);
    // Use cases are wrapped at DI bind time; for direct-injection tests,
    // wrap inline:
    const wrapped = withSpan(tracer, { name: "blog.getArticles", op: "use-case" }, getArticlesUseCase(repo));
    await wrapped({ limit: 10 });
    expect(tracer.findSpan("blog.getArticles")?.op).toBe("use-case");
    expect(tracer.findSpan("articles.getArticles")?.op).toBe("repository");
  });
});
```

**Capture assertions** use `RecordingLogger`:

```ts
const logger = new RecordingLogger();
const repo = new MockArticlesRepository(tracer, logger);
// Force an infra error in your test setup, then:
expect(logger.captures).toHaveLength(1);
expect(logger.captures[0]).toMatchObject({
  kind: "exception",
  ctx: { tags: { feature: "blog", repo: "articles", method: "getArticles" } },
});
```

**Default mocks** (when you don't need assertions): construct `new MockArticlesRepository()` with no args — constructor defaults bind `NoopTracer` + `NoopLogger`.
```

- [ ] **Step 2: Update testing-strategy.md**

Append a section after the existing factory/contract content:

```markdown
## R49 / R50 — Instrumentation testing

**R49 — No real Sentry in tests.** The `core-testing/setup/no-sentry.ts` guard mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert Sentry SDK calls add their own `vi.mock(...)` per file.

**R50 — Repository contracts assert span shape.** Every `__contracts__/<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:

```markdown
## src/instrumentation/

**Two interfaces:** `ITracer` (in `tracer.interface.ts`) and `ILogger` (in `logger.interface.ts`).

**Three implementation pairs:**
- `NoopTracer` / `NoopLogger` — pass-through. Default everywhere.
- `SentryTracer` / `SentryLogger` — adapters over `@sentry/nextjs`. Live in `sentry/` subfolder. **The `sentry/` subfolder is the only path in `packages/` permitted to import `@sentry/*`** (R40).
- `RecordingTracer` / `RecordingLogger` — in `@repo/core-testing/instrumentation`, not here.

**`with-span.ts`:** higher-order helper used at DI binding time to wrap use case + controller factory results in a span. Pattern:

```ts
const wrapped = withSpan(tracer, { name: "blog.getArticles", op: "use-case" }, factory(deps));
```

**Symbols:** `INSTRUMENTATION_SYMBOLS.TRACER`, `INSTRUMENTATION_SYMBOLS.LOGGER` (both `Symbol.for(...)` so cross-realm equality holds).

**`sentry/scrub.ts`:** PII scrubbers used by every `Sentry.init()` call across the monorepo. Substring-based key matching catches derived names (`userEmail`, `accessToken`, `apiKey`). IPv4/IPv6 are redacted from string values.

**`sentry/init-server.ts` + `init-client.ts`:** centralized init helpers that hard-code R31 (`sendDefaultPii: false`), R32/R33 (scrubbers), R34/R35 (replay mask flags), R37 (sample-rate defaults). Apps call these from `instrumentation.ts` / `instrumentation-client.ts`.

**`sentry/init-server-node.ts` + `init-client-react.ts`:** Vite/non-Next variants used by `apps/web-tanstack`.

**`di/bind-noop-instrumentation.ts` + `bind-sentry-instrumentation.ts`:** bind TRACER + LOGGER symbols to a Container. Returns the resolved instances so callers can use them without container lookup.

**Boundaries:**
- `core-shared/instrumentation/sentry/**` MAY import from `@sentry/*`.
- Everything else in `packages/core-shared/src/` MUST NOT.
- The eslint rule in `core-eslint/base.js` enforces the broader monorepo boundary (R40).
```

- [ ] **Step 5: Commit**

```bash
git add docs/guides/tdd-workflow.md \
        docs/guides/testing-strategy.md \
        docs/architecture/dependency-flow.md \
        packages/core-shared/AGENTS.md
git commit -m "docs: instrumentation testing patterns + dependency-flow + core-shared AGENTS update"
```

---

### Task 32: HTML updates — data-flow-explainer §07 + di-explainer additions

**Files:**
- Modify: `docs/architecture/data-flow-explainer.html`
- Modify: `docs/architecture/di-explainer.html`

> **Aesthetic constraint:** match the existing editorial-cream-paper / oxblood aesthetic. Don't introduce new fonts. Reuse the section-num + grid + tag patterns already in the page.

- [ ] **Step 1: Add §07 to data-flow-explainer.html**

Open `docs/architecture/data-flow-explainer.html`. Find the contents nav (the 6-column grid with §01-§06) and:

a) Update the grid template to 7 columns and add the new entry:
```html
<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):

```html
<!-- ─── 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 (R31–R38, 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):

```css
.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:

```html
<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**

```bash
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**

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

```markdown
# 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 (R31–R38).** `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 (R31–R55)
```

- [ ] **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**

```bash
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**

```bash
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 R31–R38 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.

