diff --git a/AGENTS.md b/AGENTS.md index f860fbe..020e601 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -401,9 +401,12 @@ See `docs/guides/realtime.md` and `docs/decisions/adr-016-realtime-layer.md`. ## Instrumentation conventions +Substrate: **OpenTelemetry SDK** (ADR-017). Sentry is wired as the exporter via `@sentry/opentelemetry`. Vendor swaps are exporter swaps — feature code never touches Sentry or OTel SDK directly. + **Symbols (in `core-shared/instrumentation/symbols.ts`):** -- `INSTRUMENTATION_SYMBOLS.TRACER` — bound to `ITracer` (`NoopTracer` / `SentryTracer`) -- `INSTRUMENTATION_SYMBOLS.LOGGER` — bound to `ILogger` (`NoopLogger` / `SentryLogger`) +- `INSTRUMENTATION_SYMBOLS.ITracer` — bound to `ITracer` (`NoopTracer` / `OtelTracer`) +- `INSTRUMENTATION_SYMBOLS.ILogger` — bound to `ILogger` (`NoopLogger` / `OtelLogger`) +- `INSTRUMENTATION_SYMBOLS.IMetrics` — bound to `IMetrics` (`NoopMetrics` / `OtelMetrics`) **Repository constructor signature (every feature):** @@ -470,19 +473,18 @@ const wrappedCtrl = withSpan( | Controller | `InputParseError` from `safeParse` failure (via `withCapture`) | Errors from use cases — flag set, `withCapture` bails | | `defineErrorMiddleware` | Nothing — maps domain → TRPCError only | — | -**Boundary rule (eslint-enforced, R40):** -Feature packages MUST NOT `import "@sentry/*"`. Allowlist: -- `**/instrumentation/sentry/**` (core-shared) -- `**/instrumentation/di/bind-sentry-instrumentation.{ts,test.ts}` -- `**/setup/no-sentry.{ts,js}` + the test guard -- `apps/*/instrumentation*.{ts,mjs,js}` -- `apps/*/next.config.{mjs,ts,js}` -- `apps/*/vite.config.{ts,mjs,js}` +**Boundary rules (eslint-enforced, R40 + R52):** +Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"`. Allowlists: + +- R40 (`@sentry/*`): `**/instrumentation/otel/sentry-bridge.{ts,js}`, `**/instrumentation/sentry/init-client*.{ts,js}`, `**/instrumentation/sentry/init-server*.{ts,js}`, `**/setup/no-instrumentation.{ts,js}`, `apps/*/instrumentation*.{ts,mjs,js}`, `apps/*/next.config.{mjs,ts,js}`, `apps/*/vite.config.{ts,mjs,js}` +- R52 (`@opentelemetry/sdk-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions`, `@sentry/opentelemetry`): `**/instrumentation/otel/**` + +The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`) are unrestricted within `core-shared/instrumentation/`. **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`) +- Default to `NoopTracer` / `NoopLogger` / `NoopMetrics` (constructor defaults) +- Assert spans/captures by injecting `RecordingTracer` / `RecordingLogger` / `RecordingMetrics` from `@repo/core-testing/instrumentation` +- Real Sentry SDK + OTel SDK MUST NOT initialize during tests (guarded by `core-testing/setup/no-instrumentation.ts`; old alias `no-sentry` kept for one release) --- diff --git a/CLAUDE.md b/CLAUDE.md index 3a653dd..1822ce5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,12 +61,12 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`, - **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev()` function that uses the feature's existing factory - **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders - **App bootstrap** — Each app calls `bindAll()` from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent -- **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) +- **Instrumentation lives in `core-shared/instrumentation/`** — Three interfaces (`ITracer`, `ILogger`, `IMetrics`), three implementation pairs (`Noop*`, `Otel*`, and `Recording*` from `core-testing`). The OTel SDK is the substrate; Sentry is wired as the exporter via `@sentry/opentelemetry`. Feature packages MUST NOT import `@opentelemetry/sdk-*` or `@sentry/*` directly (R40 + R52, ESLint-enforced); the vendor-neutral `@opentelemetry/api` family is the import surface for advanced cases (ADR-017) - **Spans + capture composed at DI bind time** — Use cases + controllers wrapped via `withSpan(tracer, spanOpts, withCapture(logger, tags, factory(deps)))` inside `bind-production` / `bind-dev-seed`. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they call `this.tracer.startSpan(...)` and `this.logger.captureException(...)` inline per method because they own per-call attributes (R41, R42) -- **Capture at throw sites only, with double-report guard** — Repos capture infra errors inline; use cases + controllers capture via `withCapture` at bind time; `defineErrorMiddleware` never captures (R43, R44). Each error gets a non-enumerable `__sentryReported` flag the first time it's captured; `withCapture`, `SentryLogger`, and `RecordingLogger` all bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper at `core-shared/instrumentation/reported-flag.ts`) -- **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) +- **Capture at throw sites only, with double-report guard** — Repos capture infra errors inline; use cases + controllers capture via `withCapture` at bind time; `defineErrorMiddleware` never captures (R43, R44). Each error gets a non-enumerable `__sentryReported` flag the first time it's captured; `withCapture`, `OtelLogger`, and `RecordingLogger` all bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper at `core-shared/instrumentation/reported-flag.ts`) +- **PII handling is non-negotiable** — `sendDefaultPii: false` everywhere (R31, CI grep gate); replay default-masks all text/inputs/media (R34, R35, allowlist starts empty); `setUser({ id })` only — no email/username (R36); server-side PII scrubbing happens at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before any exporter sees the data (R32, R33, ADR-017 §7) - **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 +- **Instrumentation binding is orthogonal to repo binding** — `bindAll()`'s Rule 0 (DSN → OTel+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 - **Cross-feature events go through `IEventBus` (E0)** — In-feature reactions are direct use-case calls, not bus publishes. The bus is for *crossing* feature boundaries (e.g. `auth` → `marketing-pages` welcome email) - **Event contracts are public; handlers are private (E1)** — Publisher's `events/.event.ts` is exported from the feature root barrel. Consumer's `events/handlers/on--.handler.ts` is never re-exported (ESLint-enforced via `core-eslint/rules/no-handler-reexport`) - **Jobs are for *deferred* work, not abstraction (J0)** — Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron). Feature packages enqueue via `IJobQueue` only — direct `payload.jobs.queue()` is ESLint-blocked outside `core-shared/jobs/` diff --git a/docs/architecture/data-flow-explainer.html b/docs/architecture/data-flow-explainer.html index 2445be0..d8dff40 100644 --- a/docs/architecture/data-flow-explainer.html +++ b/docs/architecture/data-flow-explainer.html @@ -2378,12 +2378,13 @@ footer .colophon {

The trace tree (one tRPC request)

-
HTTP transaction               (auto, @sentry/nextjs)
-└── tRPC procedure span        (auto, sentry trpc integration)
-    └── controller span         (op="controller", composed at DI bind time)
-        └── use-case span       (op="use-case",  composed at DI bind time)
-            └── repository span (op="repository", inline per method)
-                └── Payload Local API call (auto, @sentry/node http)
+

Substrate: OpenTelemetry SDK (ADR-017). Sentry is the exporter via @sentry/opentelemetry. Auto-instrumentations cover HTTP, undici, and pg; feature code emits via ITracer / ILogger interfaces only.

+
HTTP transaction               (auto, OTel HttpInstrumentation)
+└── tRPC procedure span        (auto, OTel + Sentry tRPC integration)
+    └── controller span         (op="controller", composed at DI bind time via OtelTracer)
+        └── use-case span       (op="use-case",  composed at DI bind time via OtelTracer)
+            └── repository span (op="repository", inline per method via ITracer)
+                └── Payload Local API call (auto, OTel PgInstrumentation / UndiciInstrumentation)

Where instrumentation actually lives

Two ways spans + captures get attached. Inline means the call appears in the layer's own body. Composed-in means a higher-order wrapper applied at DI bind time — the body stays vendor-clean.

@@ -2502,16 +2503,16 @@ const wrappedCtrl = withSpan(

Double-report guard

-

Each error gets a non-enumerable __sentryReported flag the first time it's captured. withCapture, SentryLogger, and RecordingLogger all check the flag and bail if it's set. So an error bubbling repo → use-case → controller surfaces in the logger exactly once, with the inner-most layer's tags. Helper lives in core-shared/instrumentation/reported-flag.ts.

+

Each error gets a non-enumerable __sentryReported flag the first time it's captured. withCapture, OtelLogger, and RecordingLogger all check the flag and bail if it's set. So an error bubbling repo → use-case → controller surfaces in the logger exactly once, with the inner-most layer's tags. Helper lives in core-shared/instrumentation/reported-flag.ts.

PII rules (R31–R38, non-negotiable)

  • sendDefaultPii: false — every Sentry.init(). CI grep gate.
  • Replay default-masks all text + inputs + media. Allowlist starts empty.
  • -
  • beforeSend scrubber strips email / password / token / cookie / authorization / ipaddress keys (substring match).
  • -
  • beforeSendTransaction scrubber strips PII query params from URLs.
  • -
  • setUser accepts only { id }. Stripping wrapper warns in dev when other keys passed.
  • -
  • IPv4/IPv6 in event payload string values redacted to [redacted-ip].
  • +
  • Server-side: PiiScrubSpanProcessor + PiiScrubLogRecordProcessor run FIRST in the OTel processor chain — attribute-key substring match strips email / password / token / cookie / authorization / ipaddress keys before the Sentry exporter sees the data (R32, R33, ADR-017 §7).
  • +
  • Browser-side: beforeSend / beforeSendTransaction hooks in init-client*.ts strip the same PII keys (browser does not use the OTel pipeline).
  • +
  • setUser accepts only { id }. No email/username (R36).
  • +
  • IPv4/IPv6 redacted to [redacted-ip] in browser-side scrubbers.
diff --git a/docs/architecture/dependency-flow.md b/docs/architecture/dependency-flow.md index 69c61a2..f87e64c 100644 --- a/docs/architecture/dependency-flow.md +++ b/docs/architecture/dependency-flow.md @@ -94,18 +94,21 @@ Three layers work in tandem: 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) +## TRACER / LOGGER / METRICS (ADR-014, ADR-017) -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). +The instrumentation layer is **per-feature container** but **app-wide instance**: each feature container binds `INSTRUMENTATION_SYMBOLS.ITracer`, `INSTRUMENTATION_SYMBOLS.ILogger`, and `INSTRUMENTATION_SYMBOLS.IMetrics` to the SAME instances, constructed once by the app's `bindAll()` dispatcher (Rule 0). + +**Substrate:** OpenTelemetry SDK. Sentry is the exporter via `@sentry/opentelemetry`. PII scrubbing runs at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before the Sentry exporter sees the data. Feature code is never coupled to the OTel SDK or Sentry SDK directly (ADR-017). ``` apps/web-next/src/server/bind-production.ts (bindAll) │ ├─ Rule 0: WEB_NEXT_SENTRY_DSN set? - │ yes → bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" }) + │ yes → bindOtelInstrumentation(sharedContainer, { dsn, app: "web-next" }) + │ → initOtelServerNode(dsn, ...) → OTel SDK + Sentry exporter + PII scrub processors │ no → bindNoopInstrumentation(sharedContainer) │ ↓ - │ tracer + logger instances + │ tracer (OtelTracer) + logger (OtelLogger) + metrics (OtelMetrics) instances │ ↓ ├─ resolveEventsAndJobs* → IEventBus + IJobQueue (ADR-015) │ production → PayloadJobsEventBus + PayloadJobQueue @@ -115,9 +118,9 @@ apps/web-next/src/server/bind-production.ts (bindAll) │ server.ts → SocketIORealtimeBroadcaster + RealtimeHandlerRegistry (passed in from server.ts) │ page/test → InMemoryRealtimeBroadcaster + RealtimeHandlerRegistry (defaults) │ ↓ - ├─ build ctx: BindProductionContext = { config, tracer, logger, bus, queue, realtime, realtimeRegistry } + ├─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry } │ Required: tracer, logger, config (production only) - │ Optional: bus, queue, realtime, realtimeRegistry (guard with ?. when used) + │ Optional: metrics, bus, queue, realtime, realtimeRegistry (guard with ?. when used) │ ↓ ├─ bindProductionBlog(ctx: BindProductionContext) │ │ @@ -138,8 +141,9 @@ apps/web-next/src/server/bind-production.ts (bindAll) | Field | Type | Required | Notes | |---|---|---|---| -| `tracer` | `ITracer` | always | Resolved by Rule 0 (Sentry vs Noop) | +| `tracer` | `ITracer` | always | Resolved by Rule 0 (OTel+Sentry vs Noop) | | `logger` | `ILogger` | always | Resolved by Rule 0 | +| `metrics` | `MetricsProtocol?` | optional | Resolved by Rule 0; per-feature adoption is opportunistic | | `config` | `SanitizedConfig` | production only | Present in `BindProductionContext`, absent in `BindContext` | | `bus` | `EventBusProtocol?` | optional | `IEventBus` at the aggregator; protocol surface at binders | | `queue` | `IJobQueue?` | optional | Present when `core-shared/jobs` is wired | @@ -150,6 +154,6 @@ Feature binders destructure `ctx` and use optional fields with `?.` or cast to t **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. +**Why the shared container exists at all:** isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is the exporter or not — 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. +**Boundary rule:** feature packages MUST NOT import `@sentry/*` or `@opentelemetry/sdk-*` directly (R40 + R52, ESLint-enforced). The OTel bridge (`otel/sentry-bridge.ts`), browser init files (`sentry/init-client*.ts`), and app-level `instrumentation*.{ts,mjs}` / `next.config.{mjs}` / `vite.config.{ts}` entries are the only allowlisted paths. diff --git a/docs/architecture/di-explainer.html b/docs/architecture/di-explainer.html index bb2279f..4a04221 100644 --- a/docs/architecture/di-explainer.html +++ b/docs/architecture/di-explainer.html @@ -1170,28 +1170,32 @@ footer .colophon {
§ 08

Instrumentation symbols.

-

Plan 10 added two new symbols to the per-feature container — TRACER and LOGGER — bound by a separate Rule 0 in bindAll() that's orthogonal to the repo binding mode. The DSN env var decides Sentry vs Noop; USE_DEV_SEED / NODE_ENV decide real vs mock repos.

+

ADR-014 + ADR-017 added instrumentation symbols to the per-feature container — ITracer, ILogger, and IMetrics — bound by a separate Rule 0 in bindAll() that's orthogonal to the repo binding mode. The DSN env var decides OTel+Sentry vs Noop; USE_DEV_SEED / NODE_ENV decide real vs mock repos. Substrate: OpenTelemetry SDK. Sentry is the exporter via @sentry/opentelemetry.

-

INSTRUMENTATION_SYMBOLS.TRACER

-

Bound by either bindNoopInstrumentation or bindSentryInstrumentation to NoopTracer or SentryTracer. Decided by Rule 0: DSN env present → Sentry; otherwise Noop.

+

INSTRUMENTATION_SYMBOLS.ITracer

+

Bound by either bindNoopInstrumentation or bindOtelInstrumentation to NoopTracer or OtelTracer. Decided by Rule 0: DSN env present → OTel SDK + Sentry exporter; otherwise Noop. OtelTracer emits via @opentelemetry/api; spans flow to Sentry via SentrySpanProcessor.

-

INSTRUMENTATION_SYMBOLS.LOGGER

-

Same rule, same lifecycle. NoopLogger in the absence of a DSN; SentryLogger when DSN is set. The Sentry adapter applies the __sentryReported double-report guard internally — call sites don't manage the flag.

+

INSTRUMENTATION_SYMBOLS.ILogger

+

Same rule, same lifecycle. NoopLogger in the absence of a DSN; OtelLogger when DSN is set. OtelLogger emits log records via @opentelemetry/api-logs; errors flow to Sentry via SentryLogRecordProcessor. The __sentryReported double-report guard is applied before emitting.

+ +

INSTRUMENTATION_SYMBOLS.IMetrics

+

Bound to NoopMetrics or OtelMetrics. OtelMetrics uses the OTel metrics API (counter, histogram, gauge). Per-feature adoption is opportunistic — no feature call sites required.

Wiring path

bindAll()
-  └─ resolveInstrumentation()         ← Rule 0 (DSN check)
-       └─ Noop or Sentry binders      ← bind to sharedContainer
+  └─ resolveInstrumentation()          ← Rule 0 (DSN check)
+       └─ bindOtelInstrumentation()    ← OTel SDK init + Sentry exporter + PII scrub processors
+         OR bindNoopInstrumentation()  ← all Noop
   └─ resolveEventsAndJobs*()          ← ADR-015 (env-driven bus + queue)
        └─ Payload-backed in prod, in-memory in dev-seed
-  └─ build ctx: BindProductionContext = { config, tracer, logger, bus, queue, realtime, realtimeRegistry }
-       └─ required: tracer, logger, config  |  optional: bus, queue, realtime, realtimeRegistry
+  └─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry }
+       └─ required: tracer, logger, config  |  optional: metrics, bus, queue, realtime, realtimeRegistry
   └─ bindProductionX(ctx)             ← single ctx object passed to each feature binder
        └─ feature container also binds TRACER + LOGGER
        └─ withSpan(withCapture(...)) at every use case + controller
diff --git a/docs/architecture/vertical-feature-spec.md b/docs/architecture/vertical-feature-spec.md
index beeedbb..d9febef 100644
--- a/docs/architecture/vertical-feature-spec.md
+++ b/docs/architecture/vertical-feature-spec.md
@@ -685,15 +685,17 @@ Invoke the `superpowers:writing-plans` skill to produce a detailed, executable i
 
 ---
 
-## 16. Instrumentation & error capture (Plan 10)
+## 16. Instrumentation & error capture (ADR-014, ADR-017)
 
-**Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31–R55).
+**Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31–R55 interfaces); `docs/decisions/adr-017-opentelemetry-migration.md` (OTel substrate, supersedes ADR-014 impl section).
+
+**Substrate:** OpenTelemetry SDK. Sentry is the exporter via `@sentry/opentelemetry`. PII scrubbing happens at the OTel processor layer before the Sentry exporter. Feature code depends only on `ITracer`, `ILogger`, `IMetrics` interfaces — no Sentry or OTel SDK imports.
 
 **File additions per feature:**
 
 - `infrastructure/repositories/.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/.repository.mock.ts` — same constructor/wrapping shape (no catch — mocks don't originate infra errors).
-- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, bus, queue, realtime, realtimeRegistry }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`bus`, `queue`, `realtime`, `realtimeRegistry`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them.
+- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`metrics`, `bus`, `queue`, `realtime`, `realtimeRegistry`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them.
 - `di/bind-dev-seed.ts` — signature `(ctx: BindContext)` (no `config`). Same wrapping as bind-production but with the populated mock.
 
 **Required exports (per feature root):** unchanged.
diff --git a/docs/decisions/adr-014-instrumentation-sentry.md b/docs/decisions/adr-014-instrumentation-sentry.md
index dfef6de..ce7b8f9 100644
--- a/docs/decisions/adr-014-instrumentation-sentry.md
+++ b/docs/decisions/adr-014-instrumentation-sentry.md
@@ -1,6 +1,7 @@
 # ADR-014 — Instrumentation & Sentry Logging
 
 **Status:** Accepted
+**Status (revised):** Superseded by ADR-017 for the implementation layer. The interface decisions (R31–R51) remain authoritative.
 **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
diff --git a/packages/core-shared/AGENTS.md b/packages/core-shared/AGENTS.md
index 5c16c85..f049ed0 100644
--- a/packages/core-shared/AGENTS.md
+++ b/packages/core-shared/AGENTS.md
@@ -38,13 +38,15 @@ Covered areas:
 
 ## src/instrumentation/
 
-**Two interfaces:** `ITracer` (in `tracer.interface.ts`) and `ILogger` (in `logger.interface.ts`).
+**Substrate:** OpenTelemetry SDK (ADR-017). Sentry is the exporter via `@sentry/opentelemetry`. Feature packages depend only on the interfaces below — no Sentry or OTel SDK imports.
+
+**Three interfaces:** `ITracer` (`tracer.interface.ts`), `ILogger` (`logger.interface.ts`), `IMetrics` (`metrics.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.
+- `NoopTracer` / `NoopLogger` / `NoopMetrics` — pass-through. Default everywhere.
+- `OtelTracer` / `OtelLogger` / `OtelMetrics` — emit via OTel API (`@opentelemetry/api`, `@opentelemetry/api-logs`). Live in `otel/` subfolder. **The `otel/` subfolder is the only path in `packages/` permitted to import `@opentelemetry/sdk-*` or `@sentry/opentelemetry`** (R52).
+- `RecordingTracer` / `RecordingLogger` / `RecordingMetrics` — in `@repo/core-testing/instrumentation`, not here.
 
 **`with-span.ts` + `with-capture.ts`:** two higher-order helpers used at DI binding time to wrap use case + controller factory results. The binders apply them as a sandwich — `withSpan` outermost, `withCapture` between span and factory:
 
@@ -62,29 +64,33 @@ const wrapped = withSpan(
 
 `withSpan` is pure delegation to `tracer.startSpan`. `withCapture` catches thrown errors, calls `logger.captureException(err, { tags })`, marks the `__sentryReported` flag, and re-throws — but bails if the flag is already set so the inner-most layer wins.
 
-**`reported-flag.ts`:** small module exporting `markReported(err)` and `isReported(err)`. Used by `withCapture` and `SentryLogger`. `RecordingLogger` carries an inlined copy (tooling → core import is disallowed by the boundary rule).
+**`reported-flag.ts`:** small module exporting `markReported(err)` and `isReported(err)`. Used by `withCapture` and `OtelLogger`. `RecordingLogger` carries an inlined copy (tooling → core import is disallowed by the boundary rule).
 
-**Symbols:** `INSTRUMENTATION_SYMBOLS.TRACER`, `INSTRUMENTATION_SYMBOLS.LOGGER` (both `Symbol.for(...)` so cross-realm equality holds).
+**Symbols:** `INSTRUMENTATION_SYMBOLS.ITracer`, `INSTRUMENTATION_SYMBOLS.ILogger`, `INSTRUMENTATION_SYMBOLS.IMetrics` (all `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.
+**`otel/pii-fields.ts`:** vendor-neutral PII substring list (`PII_KEY_SUBSTRINGS`, `PII_QUERY_PARAM_SUBSTRINGS`). Used by `otel/pii-scrub-processor.ts` (server) and imported by `sentry/init-client*.ts` (browser).
 
-**`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`.
+**`otel/pii-scrub-processor.ts`:** `PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`. Registered FIRST in the OTel processor chain so all downstream exporters (Sentry) see scrubbed data. Replaces Sentry's `beforeSend`/`beforeSendTransaction` hooks on the server side (ADR-017 §7).
 
-**`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`.
+**`sentry/init-server.ts` + `sentry/init-client.ts`:** centralized init helpers (Next.js flavor). `init-server.ts` calls `Sentry.init` with `sendDefaultPii: false` (R31) — no `beforeSend` hook (PII scrubbed at OTel layer). `init-client.ts` retains `beforeSend`/`beforeSendTransaction` because browser does not use the OTel pipeline.
 
-**`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.
+**`sentry/init-server-node.ts` + `sentry/init-client-react.ts`:** Vite/non-Next variants used by `apps/web-tanstack`. Same posture as their Next.js counterparts.
+
+**`di/bind-noop-instrumentation.ts` + `bind-otel-instrumentation.ts`:** bind ITracer + ILogger + IMetrics symbols to a Container. Returns the resolved instances so callers can use them without container lookup. `bindSentryInstrumentation` kept as a deprecated alias for one release.
 
 **Subpath exports** (`package.json#exports`):
 
-- `./instrumentation` — barrel (interfaces + Noops + withSpan + withCapture + reported-flag helpers + 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` — barrel (interfaces + Noops + OtelTracer/OtelLogger + withSpan + withCapture + reported-flag + symbols + binders)
+- `./instrumentation/otel` — OTel init helper + resource builder barrel
+- `./instrumentation/otel/init-server-node` — `initOtelServerNode` (app bootstrap, server-side OTel SDK)
+- `./instrumentation/sentry/init-server` — Next.js server Sentry.init helper
+- `./instrumentation/sentry/init-client` — Next.js browser Sentry.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)
+- `./instrumentation/sentry/init-client-react` — `@sentry/react` browser init (TanStack Start)
 
 **Boundaries:**
 
-- `core-shared/instrumentation/sentry/**` MAY import from `@sentry/*`.
+- `core-shared/instrumentation/otel/**` MAY import from `@opentelemetry/sdk-*` and `@sentry/opentelemetry`.
+- `core-shared/instrumentation/sentry/**` MAY import from `@sentry/*` (browser init files).
 - Everything else in `packages/core-shared/src/` MUST NOT.
-- The eslint rule in `core-eslint/base.js` enforces the broader monorepo boundary (R40).
+- ESLint rules R40 + R52 in `core-eslint/base.js` enforce the broader monorepo boundary.