Files
agentic-dev/docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md
Danijel Martinek 87674fecae docs(spec): instrumentation + Sentry logging design (R31–R55)
Defines the post-Plan-9 instrumentation subsystem: vendor-agnostic
Tracer/Logger interfaces in core-shared, full-depth tracing
(procedure → controller → use-case → repository) with use-case +
controller spans applied via withSpan at DI binding time and explicit
tracer.startSpan in every repository method, throw-site error capture
with __sentryReported double-report guard, and hard PII rules
(sendDefaultPii:false, default-mask replay, beforeSend/beforeSendTransaction
scrubbers, opaque user IDs only, build-time CI grep gate).

Three apps in scope (web-next, cms, web-tanstack), each with its own
Sentry project and DSN. Instrumentation binding is orthogonal to
USE_DEV_SEED/NODE_ENV repo binding. Optional dev-mode Sentry: NoopTracer
default, real SDK initializes only if DSN env is set.

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

475 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (R31R38), 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 (R1R30 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 R31R55 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` (R1R30, 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)