diff --git a/docs/architecture/dependency-flow.md b/docs/architecture/dependency-flow.md index db97aab..8b2d68f 100644 --- a/docs/architecture/dependency-flow.md +++ b/docs/architecture/dependency-flow.md @@ -93,3 +93,33 @@ Three layers work in tandem: - **Turborepo `boundaries`** runs at build time, validating the entire workspace graph including transitive dependencies The two enforcement layers are independent but complementary. ESLint is stricter on per-import context (e.g., file-specific exemptions via `// @boundaries-ignore`), while Turborepo catches transitive issues that lint-time checking misses. Run `pnpm lint` and `pnpm turbo boundaries` in CI to catch all violations. + +## 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 containers also get 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 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. + +**Boundary rule:** feature packages MUST NOT import `@sentry/*` directly (R40, ESLint-enforced). The only paths that may import the SDK are `core-shared/instrumentation/sentry/**`, the `bind-sentry-instrumentation` files, the `no-sentry` test guards, and per-app `instrumentation*.{ts,mjs}` / `next.config.{mjs}` / `vite.config.{ts}` entries. diff --git a/docs/guides/tdd-workflow.md b/docs/guides/tdd-workflow.md index 87b4dcc..131fad2 100644 --- a/docs/guides/tdd-workflow.md +++ b/docs/guides/tdd-workflow.md @@ -652,3 +652,49 @@ pnpm test:e2e -- --headed # visible browser ``` E2E tests live in `apps/web-next/e2e/`. The `webServer` block in `apps/web-next/playwright.config.ts` starts the dev server automatically. + +--- + +## Asserting spans and captures (Plan 10) + +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 { withSpan } from "@repo/core-shared/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 — the constructor defaults bind `NoopTracer` + `NoopLogger`. diff --git a/docs/guides/testing-strategy.md b/docs/guides/testing-strategy.md index fd3c3aa..64d8fb9 100644 --- a/docs/guides/testing-strategy.md +++ b/docs/guides/testing-strategy.md @@ -307,4 +307,27 @@ Root `turbo.json`: 2. **Feature-level tests** exercise the full feature with mocked repos (direct injection, no container) 3. **E2E tests** prove the app works end-to-end (minimal smoke specs initially) 4. **Per-feature containers** are used at the **router level**; use-case + controller tests inject mocks directly into the factory (no container) + +## R49 / R50 — Instrumentation testing (Plan 10) + +**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 specific Sentry SDK calls add their own `vi.mock(...)` per file. + +**R50 — Repository contracts assert span shape.** Every `__contracts__/-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. 5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests diff --git a/packages/core-shared/AGENTS.md b/packages/core-shared/AGENTS.md index c59a17c..be61d45 100644 --- a/packages/core-shared/AGENTS.md +++ b/packages/core-shared/AGENTS.md @@ -35,3 +35,48 @@ Covered areas: - Slug field generation + validation - Payload hooks (publish-at timestamp, slugify-if-missing) - Access control helpers + +## 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), with the additional exception of `instrumentation/di/bind-sentry-instrumentation.{ts,test.ts}`. +- `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`, `ipAddress`). IPv4/IPv6 are also redacted from string values via the `[redacted-ip]` token. + +**`sentry/init-server.ts` + `init-client.ts`:** centralized init helpers (Next.js flavor) 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`. Same R31/R32/R33/R34/R35/R37 posture; uses `@sentry/node` + `@sentry/react` instead of `@sentry/nextjs`. + +**`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. + +**Subpath exports** (`package.json#exports`): + +- `./instrumentation` — barrel (interfaces + Noops + withSpan + symbols + binders + node/react init helpers) +- `./instrumentation/sentry/init-server` — Next.js server init helper +- `./instrumentation/sentry/init-client` — Next.js client init helper +- `./instrumentation/sentry/init-server-node` — `@sentry/node` server init (TanStack Start) +- `./instrumentation/sentry/init-client-react` — `@sentry/react` client init (TanStack Start) +- `./instrumentation/sentry/scrub` — `beforeSend` / `beforeSendTransaction` (used by per-app PII test) + +**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).