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