# Instrumentation & Sentry Logging — Design Spec

**Date:** 2026-05-06
**Status:** Approved (autonomous execution authorized)
**Author:** Claude Opus 4.7 (1M context)
**Reviewer:** Danijel
**Extends:** ADRs 008 (per-feature DI), 011 (TDD foundation), 012 (Lazar conformance), 013 (input/output unification).
**Inspired by:** Lazar Nikolov's `nextjs-clean-architecture` reference repo (`Sentry.startSpan` + `Sentry.captureException` pattern) — adapted for a multi-app monorepo with per-feature DI containers and a strict `core-shared`-only vendor boundary.

---

## 1. Goal

Add distributed tracing and error capture to the monorepo with three properties:

1. **Vendor-agnostic interfaces.** Feature packages MUST NOT import from `@sentry/*` anywhere. They depend only on a `Tracer` and `Logger` interface defined in `core-shared`. Sentry is one implementation behind that interface, swappable like any other DI binding.
2. **Full-depth visibility.** Traces span every meaningful boundary — tRPC procedure → controller → use case → repository — so a slow request can be diagnosed without re-reading code or adding ad-hoc timers.
3. **Explicit, audit-ready PII handling.** Replay, breadcrumbs, and event payloads default-mask everything; a small allowlist opts in to unmask. PII handling is a top-level design concern (R31–R38), not a configuration footnote.

Three production-facing apps are in scope: `web-next`, `cms`, `web-tanstack`. (Storybook is excluded — dev tool only.) Each gets its own Sentry project, its own DSN env var, and its own `instrumentation.ts` / `instrumentation-client.ts`.

The success criterion is operational: when a user reports a slow page or a silent failure, the engineer should be able to open Sentry, find the trace from the user's session, see the full nested span tree (request → procedure → controller → use case → repository → Payload op) with attributes documenting *what* each layer was doing, and know within seconds where the problem lives.

---

## 2. Non-goals

- **No general-purpose structured logging pipeline.** This spec covers *exception capture* and *distributed tracing*. A `console.log → Sentry` forwarder, a pino setup, or a log-aggregation backend is not in v1.
- **No custom OpenTelemetry exporter or independent OTel backend.** Sentry's built-in tracer is sufficient. We do not stand up Tempo/Jaeger/Honeycomb in parallel.
- **No `@sentry/profiling-node` profiling.** CPU profiling is a future addition, not v1.
- **No `Sentry.showReportDialog()` user-facing error reporter.** Out of scope; can be added per-app later.
- **No PII allowlist seeding.** Per R34, the replay unmask allowlist starts empty. Adding selectors is a follow-up PR with justification, not part of this spec's execution.
- **No retroactive change to existing capture call sites.** There are none today (clean slate). We do not refactor existing error-handling code beyond what the new pattern requires.
- **No instrumentation for `apps/storybook`.** Storybook is a development tool; instrumenting it adds noise without operational value.
- **No replacement of `defineErrorMiddleware`.** The middleware's contract (map domain errors → `TRPCError`) is unchanged. It does NOT gain capture responsibility (R43).

---

## 3. Rules (the contract — RFC 2119 language)

These rules extend the post-Plan-9 architecture (R1–R30 from the I/O-unification spec). Numbering continues from R31.

### 3.A — PII rules (highest priority — non-negotiable)

**R31 — `sendDefaultPii` MUST be `false`.** Every `Sentry.init()` call across all three apps and both server/browser SDKs MUST set `sendDefaultPii: false`. An ESLint rule (or a CI grep step) MUST fail the build if a `sendDefaultPii: true` literal appears anywhere in the repo.

**R32 — `beforeSend` scrubber MUST run on every event.** A shared scrubber function (in `core-shared/src/instrumentation/sentry/scrub.ts`) MUST deep-walk every event payload and strip values whose key contains (case-insensitive substring match) any of: `email`, `password`, `token`, `cookie`, `authorization`, `set-cookie`, `x-api-key`, `apikey`, `api_key`, `secret`. Substring (not exact) match is required so derived keys like `userEmail`, `accessToken`, `apiKey` are also caught. The scrubber MUST also redact IPv4 and IPv6 addresses found in string values (replaced with `"[redacted-ip]"`). Every `Sentry.init()` MUST pass this scrubber to `beforeSend`.

**R33 — `beforeSendTransaction` scrubber MUST sanitize URLs.** A shared transaction scrubber MUST strip the values of query-string params whose keys contain (case-insensitive substring match) any of: `token`, `email`, `password`, `key`, `sig`, `signature`, `access_token`, `accesstoken`, `secret`. The scrubber MUST act on `event.request.url`, `event.transaction`, and any nested URL fields, replacing matched values with `"[redacted]"`. Every `Sentry.init()` MUST pass this scrubber to `beforeSendTransaction`.

**R34 — Replay MUST default-mask everything.** `replayIntegration({ maskAllText: true, maskAllInputs: true, blockAllMedia: true })` MUST be the only configuration shape. An "unmask allowlist" (CSS selector list, configured per-app in `instrumentation-client.ts`) MAY pass selectors to `unmask` / `unblock` options, but the allowlist MUST start empty. Every entry added later MUST have an inline comment justifying why the content is non-PII.

**R35 — Replay-mask flags are not configurable.** `maskAllText`, `maskAllInputs`, `blockAllMedia` MUST always be `true`. They are set as literal `true` in the init code, not derived from env vars. Code review MUST reject any PR that parameterizes these flags.

**R36 — User context is opaque IDs only.** `Sentry.setUser({ id })` MUST be the only call site shape. The `SentryLogger.setUser` adapter MUST strip any keys other than `id` (e.g., `email`, `username`, `ip_address`) before forwarding to the SDK, and MUST emit a `console.warn` in dev when stripping occurs (so misuse is visible during development). The wrapper MUST NOT throw — production code paths MUST NOT break because someone accidentally passed `email`.

**R37 — Sample rates from env; replay session rate defaults to `0.0`.** `tracesSampleRate` MUST come from `SENTRY_TRACES_SAMPLE_RATE` (default `1.0` in dev, `0.1` in prod). `replaysSessionSampleRate` MUST default to `0.0` (no recording of healthy sessions). `replaysOnErrorSampleRate` MUST default to `1.0` (record every errored session). Production overrides MAY be set via env, but defaults MUST favor privacy.

**R38 — Each app MUST have a PII scrubber test.** Per app, a vitest test MUST construct a synthetic event with `email`, `password`, `cookie`, IP, and a query string with `?token=secret`, run it through both scrubbers, and assert all PII fields are redacted. Test file: `apps/<app>/src/__tests__/sentry-pii-scrubber.test.ts`.

### 3.B — Architecture & boundary rules

**R39 — Tracer/Logger live in `core-shared/src/instrumentation/`.** Two interface files (`tracer.interface.ts`, `logger.interface.ts`) define `ITracer`, `ISpan`, `ILogger`, `CaptureContext`, `SpanOpts`, `Breadcrumb`. Three implementations live alongside: `noop-tracer.ts`, `noop-logger.ts`, and `sentry/sentry-tracer.ts` + `sentry/sentry-logger.ts`. **Within `packages/`** (feature packages, core packages), the `core-shared/src/instrumentation/sentry/` subfolder is the only location permitted to `import` from `@sentry/*`. Apps (which need `withSentryConfig` from `@sentry/nextjs` and `@sentry/vite-plugin`) have a separate, narrowly-scoped allowlist in R40 — they MUST NOT make runtime SDK calls (e.g. `Sentry.captureException`, `Sentry.startSpan`) anywhere; only build-time wrappers and the dedicated `instrumentation.ts` / `instrumentation-client.ts` files may import from `@sentry/*`.

**R40 — Boundary rule enforced by ESLint.** A `no-restricted-imports` rule MUST forbid `@sentry/*` imports outside this allowlist:
- `packages/core-shared/src/instrumentation/sentry/**` (the adapter implementations)
- `apps/*/instrumentation.ts` and `apps/*/instrumentation-client.ts` (Next.js / TanStack server-boot hooks)
- `apps/*/next.config.{mjs,ts}` and `apps/*/sentry.*.config.{ts,mjs,js}` (build-time wrappers like `withSentryConfig`)
- `apps/web-tanstack/vite.config.{ts,mjs}` (for `@sentry/vite-plugin`)

Feature packages, controllers, use cases, and repositories MUST NOT import Sentry directly. The rule MUST be in `packages/core-eslint` so it applies repo-wide.

**R41 — Use case + controller spans applied via `withSpan` at DI binding time.** Use case and controller factory bodies MUST NOT call `tracer.startSpan` directly. Instead, `bindProductionX` and `bindDevSeedX` MUST wrap the resolved factory result with `withSpan(tracer, { name, op }, fn)` from `core-shared/instrumentation/with-span.ts` before passing it to `.toConstantValue(...)`. This keeps factory code identical to today.

**R42 — Repository methods MUST emit explicit spans.** Every public method on every concrete repository implementation (real Payload + mock) MUST wrap its body in `tracer.startSpan({ name: "<entity>.<method>", op: "repository", attributes: {...} }, async (span) => { ... })`. Mock repositories use `NoopTracer` by default but the `startSpan` call MUST be present, so mock and real share the same span emission shape (verifiable in contract tests).

**R43 — `captureException` is throw-site only.** `Sentry.captureException` (via `ILogger.captureException`) MUST be called only at the layer that *originates* the error:

| Layer | Captures | Doesn't capture |
|---|---|---|
| Repository | Infra/Payload errors that originate here | Errors bubbling from below |
| Use case | Business-rule violations originated in this body | Errors from repos (already captured) |
| Controller | `InputParseError` from `safeParse` failure | Anything else |
| `defineErrorMiddleware` | **Nothing** | Continues to map domain → `TRPCError` only |

**R44 — `SentryLogger.captureException` MUST guard against double-report.** The adapter MUST check for a non-enumerable `__sentryReported` property on the error object; if present, return early. Otherwise, call `Sentry.captureException`, then mark the error via `Object.defineProperty(err, "__sentryReported", { value: true })`. Call sites MUST NOT manage this flag manually.

**R45 — Span name format is fixed.** Use case spans: `<feature>.<useCase>` (e.g. `"blog.getArticles"`, `"auth.signIn"`). Controller spans: same name with `op: "controller"`. Repository spans: `<entity>.<method>` (e.g. `"articles.findAll"`, `"users.create"`). The convention MUST be consistent across features for searchability in Sentry.

**R46 — Each app has its own DSN; no cross-app reuse.** `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN` MUST be three distinct Sentry projects. The browser DSNs (`NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN`, `VITE_WEB_TANSTACK_SENTRY_DSN`) point at the same projects as their server counterparts (one project covers an app's server + browser).

**R47 — Instrumentation binding mode is orthogonal to repo binding mode.** `bindAll()` MUST evaluate "use Sentry vs. use Noop" as a separate rule from "use real Payload vs. dev seed." Concretely:
- DSN set → `bindSentryInstrumentation`
- DSN unset → `bindNoopInstrumentation`

This rule is independent of `USE_DEV_SEED` and `NODE_ENV`. A developer running `pnpm dev` with `SENTRY_DSN` set in `.env.local` gets dev seed repos *and* real Sentry capture — useful for testing the integration itself.

**R48 — NoopTracer/NoopLogger MUST be the default everywhere DSN is absent.** This applies regardless of `NODE_ENV`. Production deployments without a DSN configured (e.g. preview environments) MUST function correctly with no instrumentation, not crash.

### 3.C — Testing rules

**R49 — Tests MUST use Noop or Recording, never real Sentry.** The vitest setup in `core-testing/src/vitest.setup.ts` MUST bind `NoopTracer` + `NoopLogger` by default. Tests asserting on capture/span calls MUST inject a `RecordingLogger` or `RecordingTracer` directly via the factory function (consistent with the existing direct-injection test pattern, R27 from Plan 9). The `@sentry/*` packages MUST NOT initialize during `pnpm test` — verified by a build-time check that no process under vitest has `Sentry.getCurrentHub().getClient()` returning a real client.

**R50 — Repository contract suites assert on span shape.** `defineContractSuite` (in `core-testing`) MUST gain an optional `expectSpan` helper. For every repository method tested, the suite asserts a span was emitted with the expected `name`, `op: "repository"`, and a documented set of attributes. This catches drift where a repo method is added but its span is forgotten.

### 3.D — Operational rules

**R51 — Replay allowlist additions require code-review justification.** Any PR that adds a CSS selector to the replay `unmask` or `unblock` allowlist MUST include an inline code comment (above the entry) explaining why the content is verifiably non-PII. PR template SHOULD prompt reviewers to confirm.

**R52 — Source map upload uses a build-time-only token.** `SENTRY_AUTH_TOKEN` MUST be present only at build time (CI environment). It MUST NOT be exposed to runtime or to client bundles. `withSentryConfig` MUST be configured with `silent: process.env.CI !== "true"` so missing-token warnings are visible in CI logs but suppressed locally.

**R53 — Release tag is the git SHA.** Every `Sentry.init()` MUST set `release: process.env.VERCEL_GIT_COMMIT_SHA ?? execSync("git rev-parse HEAD").toString().trim()`. This ensures source-map symbolication works correctly across deploys.

**R54 — Refactor changelog at `docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md`.** Following the pattern of Plans 8 and 9 (R30 of Plan 9), every commit landing as part of this work MUST be tracked in the changelog with task number, file paths, and rationale.

**R55 — ADR-014 documents the decisions.** A new `docs/decisions/adr-014-instrumentation-sentry.md` MUST be added in the final commit, recording: vendor-agnostic interface in `core-shared`; per-feature DI binding for tracer/logger; full-depth instrumentation; throw-site capture; PII rules; per-app DSNs; orthogonal binding mode.

---

## 4. Architecture

### 4.1 The instrumentation subsystem in `core-shared`

```
packages/core-shared/src/instrumentation/
├── index.ts                          — re-exports interfaces + Noop impls
├── tracer.interface.ts               — ITracer, ISpan, SpanOpts, AttributeValue
├── logger.interface.ts               — ILogger, CaptureContext, Breadcrumb
├── noop-tracer.ts                    — pass-through ITracer
├── noop-logger.ts                    — pass-through ILogger
├── with-span.ts                      — higher-order wrapper for DI bind-time
├── symbols.ts                        — TRACER, LOGGER (DI symbols)
├── sentry/
│   ├── sentry-tracer.ts              — adapter: ITracer → @sentry/nextjs
│   ├── sentry-logger.ts              — adapter: ILogger → @sentry/nextjs
│   ├── scrub.ts                      — beforeSend + beforeSendTransaction
│   ├── init-server.ts                — Sentry.init() helper for server runtime
│   ├── init-client.ts                — Sentry.init() helper for browser runtime
│   └── pii-fields.ts                 — regex constants for R32/R33
├── di/
│   ├── bind-noop-instrumentation.ts  — binds NoopTracer + NoopLogger
│   └── bind-sentry-instrumentation.ts — binds SentryTracer + SentryLogger
└── *.test.ts                         — unit tests per file
```

### 4.2 Interface shapes

```ts
// 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>;
}

// 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;
}
```

### 4.3 The `withSpan` higher-order wrapper (DI binding-time use case + controller spans)

```ts
// core-shared/src/instrumentation/with-span.ts
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));
  };
}
```

Used in feature `bind-production.ts` like:

```ts
// packages/blog/src/di/bind-production.ts (excerpt)
const tracer = container.get<ITracer>(SHARED_SYMBOLS.TRACER);
const articlesRepo = new PayloadArticlesRepository(config, tracer);   // repo gets tracer for explicit spans
const wrappedUseCase = withSpan(tracer, { name: "blog.getArticles", op: "use-case" },
  getArticlesUseCase(articlesRepo));
container.bind(BLOG_SYMBOLS.GetArticlesUseCase).toConstantValue(wrappedUseCase);

const wrappedController = withSpan(tracer, { name: "blog.getArticles", op: "controller" },
  getArticlesController(wrappedUseCase));
container.bind(BLOG_SYMBOLS.GetArticlesController).toConstantValue(wrappedController);
```

### 4.4 Repository explicit spans (R42)

Every public method on every repo wraps its body. Concrete shape:

```ts
// packages/blog/src/infrastructure/repositories/articles.repository.ts (excerpt)
async findAll(input: FindAllInput): Promise<Article[]> {
  return this.tracer.startSpan(
    { name: "articles.findAll", op: "repository", attributes: { collection: "articles", limit: input.limit ?? null } },
    async (span) => {
      try {
        const result = await this.payload.find({ collection: "articles", limit: input.limit, ... });
        span.setAttribute("count", result.docs.length);
        return result.docs.map(toArticle);
      } catch (err) {
        this.logger.captureException(err, { tags: { feature: "blog", repo: "articles", method: "findAll" } });
        span.setStatus("error", err instanceof Error ? err.message : String(err));
        throw err;
      }
    },
  );
}
```

The mock repository (`articles.repository.mock.ts`) uses the same shape but with the `NoopTracer` injected by default — the `startSpan` call still runs, but it's a pass-through. This keeps mock and real symmetric (verifiable via R50).

### 4.5 The `bindAll()` dispatcher with the orthogonal instrumentation rule

```ts
// apps/web-next/src/server/bind-production.ts (excerpt — post-spec)
import { bindNoopInstrumentation } from "@repo/core-shared/di/bind-noop-instrumentation";
import { bindSentryInstrumentation } from "@repo/core-shared/di/bind-sentry-instrumentation";

export async function bindAll(): Promise<void> {
  if (bound) return;
  bound = true;

  // Rule 0: instrumentation — DSN presence decides, independent of repo mode
  const dsn = process.env.WEB_NEXT_SENTRY_DSN;
  if (dsn) {
    await bindSentryInstrumentation(container, { dsn, environment: ..., release: ... });
  } else {
    bindNoopInstrumentation(container);
  }

  // Rules 1-3: repo mode (existing logic — unchanged; bound flag already set above)
  if (process.env.USE_DEV_SEED === "true") return bindAllDevSeed();
  if (process.env.NODE_ENV === "production") return bindAllProduction();
  return bindAllDevSeed();
}
```

The instrumentation step runs *first* so feature binders (`bindProductionBlog`, `bindDevSeedBlog`, etc.) can resolve `TRACER` and `LOGGER` from the container during their own setup.

### 4.6 Per-app integration files

#### `apps/web-next/`

- **`instrumentation.ts`** (Next.js convention; runs on server boot)
  ```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.WEB_NEXT_SENTRY_DSN, app: "web-next" });
    }
  }
  ```
- **`instrumentation-client.ts`** (Next.js 15+ browser hook)
  ```ts
  import { initSentryClient } from "@repo/core-shared/instrumentation/sentry/init-client";
  initSentryClient({ dsn: process.env.NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN, app: "web-next" });
  ```
- **`next.config.mjs`** wrapped with `withSentryConfig({ silent: process.env.CI !== "true", authToken: process.env.SENTRY_AUTH_TOKEN, ... })`.
- **`src/__tests__/sentry-pii-scrubber.test.ts`** (R38).

#### `apps/cms/`

Server-only Payload host. Same pattern minus `instrumentation-client.ts` and the browser DSN. `cms/src/instrumentation.ts` plus `withSentryConfig` on the Next config that hosts Payload's admin UI.

#### `apps/web-tanstack/`

TanStack Start uses Vite. The init pattern moves to TanStack's own server entry hook (`src/server.ts`) and a Vite plugin for source-map upload (`@sentry/vite-plugin`). Browser SDK init lives in the client entry. Env var prefix is `VITE_` for client.

### 4.7 Env vars (declared in `turbo.json` `globalEnv`)

| Var | Scope | Purpose |
|---|---|---|
| `WEB_NEXT_SENTRY_DSN` | Server runtime | web-next server SDK init |
| `NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN` | Client bundle | web-next browser SDK init |
| `CMS_SENTRY_DSN` | Server runtime | cms server SDK init |
| `WEB_TANSTACK_SENTRY_DSN` | Server runtime | web-tanstack server SDK init |
| `VITE_WEB_TANSTACK_SENTRY_DSN` | Client bundle | web-tanstack browser SDK init |
| `SENTRY_AUTH_TOKEN` | Build only | Source-map upload (CI only; never bundled) |
| `SENTRY_TRACES_SAMPLE_RATE` | Server + client | Default `1.0` dev, `0.1` prod |
| `SENTRY_ENVIRONMENT` | Server + client | Auto-derived: `VERCEL_ENV ?? NODE_ENV` |

All eight MUST be added to `turbo.json` `globalEnv`.

---

## 5. Data flow with spans + capture

For a single tRPC request `blog.getArticles({ limit: 10 })`:

```
1. Browser:  user clicks → trpc.blog.getArticles.useQuery({ limit: 10 })
             → Sentry browser SDK: HTTP transaction span starts
2. Network:  POST /api/trpc/blog.getArticles  (sentry-trace + baggage headers carry parent context)
3. Server:   @sentry/nextjs HTTP integration: server transaction starts (parent = browser transaction)
4. tRPC:     auto-instrumented procedure span "blog.getArticles" (from tRPC integration)
5. Middleware: defineErrorMiddleware sets active scope tags { feature: "blog", procedure: "getArticles" }
6. Controller (DI-wrapped): span "blog.getArticles" (op="controller")
   ├─ inputSchema.safeParse({ limit: 10 })  — no span (too granular per R45)
   └─ Use case (DI-wrapped): span "blog.getArticles" (op="use-case")
      └─ Repository (explicit per R42): span "articles.findAll" (op="repository", attributes={ collection: "articles", limit: 10 })
         └─ Payload Local API (auto-instrumented by Sentry): span for the DB query
   └─ presenter(result) — no span
   ← returns view-model
7. Middleware exit: success → tRPC serializes response
8. Server: transaction ends; sent to Sentry per sample rate
9. Browser: transaction continues until response handled; sent to browser-side Sentry
```

**Failure variant** (Payload throws on step 6's repo call):
- Repo `catch` block calls `this.logger.captureException(err, { tags: { feature: "blog", repo: "articles", method: "findAll" } })` (R43).
- `SentryLogger.captureException` checks `err.__sentryReported` — false; sends to Sentry; sets `__sentryReported = true` (R44).
- Repo span gets `setStatus("error", message)`; re-throws.
- Use case span propagates error status (no capture — already captured).
- Controller span propagates.
- `defineErrorMiddleware` catches, looks up `[Ctor, code]` map, throws `TRPCError(code, { cause: err })`. **Does not capture again** (R43).
- tRPC sends error response to client; client SDK records the failed transaction.

Result: one event in Sentry, with the full nested span tree as context, attributes documenting *what* the repo was doing, and the error fingerprinted by class + procedure.

---

## 6. Migration order

Each step is one commit, TDD-d. Final ordering goes into the plan.

1. **Scaffold.** Spec (this doc) + refactor log (`docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md`) + initial empty ADR-014 stub.
2. **Interfaces + Noop.** `core-shared/src/instrumentation/{tracer,logger}.interface.ts`, `noop-tracer.ts`, `noop-logger.ts`, `symbols.ts`, `with-span.ts`, `index.ts`. Unit tests for Noop and `withSpan`.
3. **Sentry adapters + scrubbers.** `core-shared/src/instrumentation/sentry/{sentry-tracer,sentry-logger,scrub,pii-fields,init-server,init-client}.ts`. Unit tests for `SentryTracer.startSpan` (mocking Sentry's exported `startSpan`), `SentryLogger.captureException` double-report guard, `scrub` against fixture events.
4. **DI binders + bindAll dispatcher rule.** `core-shared/src/di/bind-{noop,sentry}-instrumentation.ts`. Update `apps/web-next/src/server/bind-production.ts` `bindAll()` with the orthogonal rule. Tests asserting (a) DSN absent → Noop bound; (b) DSN present → Sentry bound; (c) instrumentation choice independent of `USE_DEV_SEED`/`NODE_ENV`.
5. **Test infrastructure (`core-testing`).** `RecordingTracer`, `RecordingLogger`, `bindRecordingInstrumentation`. `vitest.setup.ts` updated to bind Noop by default. Self-tests for Recording.
6. **Per-feature wiring — blog (pilot).** Update `bind-production.ts` + `bind-dev-seed.ts` to wrap use-case + controller factories with `withSpan`. Update every method on `PayloadArticlesRepository` and `MockArticlesRepository` to use `tracer.startSpan` (R42). Use case + repo + controller tests updated for direct-injection of `RecordingLogger`/`RecordingTracer` where assertions matter.
7. **Per-feature wiring — auth, marketing-pages, navigation, media.** Same pattern as step 6, one feature per commit.
8. **Contract suite span assertions.** Add `expectSpan` helper to `defineContractSuite`; update every repo contract test to assert span shape (R50).
9. **App integration — web-next.** `instrumentation.ts`, `instrumentation-client.ts`, `next.config.mjs` `withSentryConfig`, `src/__tests__/sentry-pii-scrubber.test.ts`, env vars in `.env.example` and `turbo.json` `globalEnv`.
10. **App integration — cms.**
11. **App integration — web-tanstack.** Vite-specific (different env prefix, `@sentry/vite-plugin`).
12. **ESLint boundary rule.** `no-restricted-imports` blocking `@sentry/*` outside the allowed paths (R40). CI grep step for `sendDefaultPii: true` (R31).
13. **Doc updates.** CLAUDE.md, AGENTS.md, vertical-feature-spec.md, tdd-workflow.md, testing-strategy.md, dependency-flow.md additions for the new symbols, binding rule, throw-site-capture rule, PII discipline.
14. **HTML updates.** Add §07 "Tracing & error capture" to `docs/architecture/data-flow-explainer.html` (trace tree visualization, capture rule table, PII rules summary). Update `docs/architecture/di-explainer.html` with the new TRACER/LOGGER symbols and the orthogonal binding rule.
15. **ADR-014 final.** Document decisions, alternatives considered, consequences.

---

## 7. Testing strategy

### Unit tests (vitest)
- `NoopTracer.startSpan` returns the function result; `ISpan` methods are no-ops.
- `withSpan` calls `tracer.startSpan(opts, () => fn(...args))` and returns the result.
- `SentryLogger.captureException` calls `Sentry.captureException` once, marks `__sentryReported`, second call returns early.
- `SentryTracer.startSpan` delegates to Sentry's exported `startSpan`, passing through the function and result.
- `scrub.beforeSend` strips email/password/cookie/auth/IP from a fixture event (R32).
- `scrub.beforeSendTransaction` strips `?token=`/`?email=`/`?password=`/`?key=` from URLs (R33).

### DI tests
- `bindAll()` rule independence (R47): four matrix cases verifying repo mode and instrumentation mode are independent.
- `bindNoopInstrumentation` populates `TRACER` + `LOGGER` symbols.
- `bindSentryInstrumentation` initializes Sentry with the configured DSN and binds the SDK-backed adapters.

### Contract tests
- Every repo contract suite gains `expectSpan` assertions per method (R50).

### Integration tests (per app)
- `apps/<app>/src/__tests__/sentry-pii-scrubber.test.ts` (R38) — synthetic event with PII fields → run scrubbers → assert all redacted.

### What is **not** tested
- Actual Sentry transport (we do not stand up a mock Sentry server). The adapter delegating correctly is the test boundary.
- Replay flag enforcement at runtime (R35) — tests assert the init code passes the literal flags; we trust the SDK to honor them.

---

## 8. Out of scope (deferred — explicit non-goals)

- Structured logging pipeline (pino, log forwarding) — separate effort if needed.
- `@sentry/profiling-node` profiling integration — future addition.
- Sentry Dashboards / Alert rules / on-call routing config — operational concern, lives in Sentry org settings, not in repo.
- `Sentry.showReportDialog()` — not in v1.
- Replay allowlist content — empty at v1; populated incrementally with per-PR justification (R34, R51).
- `apps/storybook` instrumentation — out of scope; storybook is a dev tool.
- Migration of any existing capture/log code — there is none today (clean slate).
- Performance budgets / bundle-size CI gates for the SDK additions — separate concern; this spec only documents expected size impact (~60KB browser SDK with replay, ~250KB peak when replay activates on error).

---

## 9. Risks & mitigations

| Risk | Mitigation |
|---|---|
| Per-repo-method span boilerplate becomes noisy across ~30 methods. | Boilerplate is uniform (always wraps the body in `tracer.startSpan` with the same pattern); a shared `withRepoSpan` helper MAY be added if duplication exceeds tolerance, but starting explicit per R42 keeps every span visible at the call site. |
| `__sentryReported` flag pollutes error objects. | Property is non-enumerable (won't appear in JSON.stringify or spread); only checked inside `SentryLogger`. Acceptable cost for double-report prevention. |
| Replay bundle size hits performance budget. | Default `replaysSessionSampleRate: 0.0` means replay code is loaded but recordings are only captured on errored sessions. Peak bundle impact is acknowledged in §8; not gated by this spec. |
| Developer accidentally sets `sendDefaultPii: true` during a debugging session. | R31 ESLint/CI rule fails the build. PR can't merge. |
| Sentry SDK initializes during `pnpm test`. | `vitest.setup.ts` binds NoopTracer/NoopLogger; SDK is never imported in test paths because feature code uses interface-only imports (R39). Verified by R49. |
| Three Sentry projects to administer (vs. one). | Accepted cost — better quotas, cleaner alert routing, no cross-app noise. |
| Build-time `SENTRY_AUTH_TOKEN` exposure risk. | Token is server-only env, never `NEXT_PUBLIC_`/`VITE_` prefixed; `withSentryConfig` strips it from runtime bundles by design. |
| Vendor lock-in to Sentry. | Mitigated by the interface boundary (R39, R40). Swapping to OTel + a different exporter means writing one new adapter in `core-shared/instrumentation/<vendor>/`; feature code does not change. |

---

## 10. Acceptance criteria

This spec is satisfied when:
- All R31–R55 rules are implemented and verifiable.
- `pnpm build` succeeds in all three apps with and without DSN env vars set.
- `pnpm test` passes; no Sentry SDK initialization in any test process.
- Every repo method has an explicit `tracer.startSpan` call (R42).
- Every use-case + controller is wrapped via `withSpan` at DI bind time (R41).
- All PII scrubber tests pass (R38).
- ESLint boundary rule rejects a synthetic `import "@sentry/nextjs"` outside permitted paths (R40).
- The CI grep for `sendDefaultPii: true` returns no matches across the repo (R31).
- `docs/architecture/data-flow-explainer.html` has §07 documenting the trace tree, capture rules, and PII rules.
- `docs/architecture/di-explainer.html` documents the new TRACER/LOGGER symbols and orthogonal binding rule.
- ADR-014 is committed and explains the design decisions.
- Refactor log at `docs/superpowers/refactor-logs/2026-05-06-instrumentation-sentry.md` is complete.
- A live test against a real Sentry project verifies: (a) a slow request produces a nested trace; (b) a thrown infra error produces exactly one captured event with the documented tags/attributes; (c) replay records an errored session with all text masked; (d) PII-bearing events are visibly redacted in the Sentry UI.

---

## 11. Decision log

- **Tracer/Logger interface in `core-shared` vs. direct Sentry imports in features.** Chose interface (R39) — preserves the architecture's vendor-isolation principle (use cases don't know who Payload is; they shouldn't know who Sentry is).
- **Full Lazar depth (procedure + controller + use-case + repository spans) vs. procedure-only.** Chose full depth — explicit per-repo `startSpan` boilerplate is the price of admission for trace-driven debugging without ad-hoc timers.
- **Throw-site capture vs. middleware capture.** Chose throw-site (R43) — avoids noisy capture of expected domain errors (input validation, unauthenticated, etc.) and gives capture calls richer context (the repo knows the collection/op; the middleware doesn't).
- **`__sentryReported` flag for double-report prevention** vs. centralized capture only at one layer. Chose flag (R44) — allows multi-layer capture if/when a use case wants to capture its own throws *and* lets repos capture, without coordinating call sites.
- **Three Sentry projects vs. one with environment tags.** Chose three (R46) — separate alert routing, separate quotas, cleaner per-app debugging UX.
- **Optional dev Sentry vs. always-Noop in dev.** Chose optional (R47/R48) — DSN-presence-toggles-mode is the simplest rule, and it lets developers test the integration locally.
- **Replay default-mask + empty allowlist** vs. configurable mask flags. Chose mandatory mask (R34, R35) — privacy posture must be the default; opt-out requires per-selector justification (R51).
- **`sendDefaultPii: false` enforced via build-time check** vs. trust developers. Chose enforcement (R31) — too-easy-to-forget settings get forgotten; build-time check makes the policy unbreakable.

---

## 12. References

- ADR-008 — per-feature DI containers
- ADR-011 — TDD foundation
- ADR-012 — Lazar pattern conformance
- ADR-013 — input/output unification
- `docs/superpowers/specs/2026-05-06-input-output-unification-design.md` (R1–R30, the rule numbering this spec extends)
- Lazar Nikolov's `nextjs-clean-architecture` reference repo (Sentry pattern source)
- Sentry docs — `@sentry/nextjs` instrumentation hooks, `replayIntegration` config, `beforeSend` API
- Sentry docs — Distributed tracing across HTTP boundaries (sentry-trace + baggage headers)
