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