diff --git a/docs/superpowers/specs/2026-05-11-opentelemetry-migration-design.md b/docs/superpowers/specs/2026-05-11-opentelemetry-migration-design.md new file mode 100644 index 0000000..0c8f1b2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-opentelemetry-migration-design.md @@ -0,0 +1,406 @@ +# OpenTelemetry migration — Design + +**Date:** 2026-05-11 +**Status:** Draft (pending user review) +**Companion ADR:** ADR-017 (to be created during implementation; supersedes ADR-014's implementation section while keeping its interface decisions R31-R51 authoritative). +**Builds on:** ADR-014 (instrumentation + Sentry), ADR-008 (per-feature DI). + +--- + +## 1. Context and motivation + +ADR-014 established vendor-neutral `ITracer` + `ILogger` interfaces in `core-shared/instrumentation/` with Sentry as the active backend. The interface decisions have held up well — feature code is decoupled from the vendor SDK, PII rules are enforced via Sentry's `beforeSend`/`beforeSendTransaction` hooks, and the DI binding pattern (`bindSentryInstrumentation` vs `bindNoopInstrumentation`) is orthogonal to repo-mode selection. + +What the current setup couples to is the **Sentry SDK as the substrate**: `SentryTracer` calls `Sentry.startSpan` directly; `SentryLogger` calls `Sentry.captureException` directly. Swapping Sentry for another observability vendor (Honeycomb, Datadog, Grafana Cloud, Tempo, etc.) requires writing new `*Tracer` and `*Logger` impls for each vendor. + +The OpenTelemetry standard solves this by separating **instrumentation API** (vendor-neutral) from **exporters** (vendor-specific). Code emits OTel spans, logs, and metrics; exporters route them to one or more backends. Sentry's `@sentry/opentelemetry` package provides exporters that consume OTel signals and forward to Sentry's UI. + +This design migrates server-side instrumentation to: +- **OTel SDK as the substrate** for traces, logs, and metrics. +- **Sentry as one exporter** (initially the only one wired). Vendor swaps are exporter swaps; logger/tracer/metrics impls never change. +- **Server-only scope.** Browser keeps Sentry SDK directly (replay + native error UX). A future migration may extend to the browser when OTel-Browser matures. + +Driver: vendor neutrality on the back end of the pipeline, opening the door to multi-vendor observability or vendor replacement without rewriting impls. Secondary: standardized auto-instrumentations (HTTP, fetch, pg) reduce boilerplate; metrics signal becomes available without a new bespoke interface. + +## 2. Decision summary + +1. Migrate server-side instrumentation from Sentry-direct SDK calls to **OpenTelemetry SDK** with `@sentry/opentelemetry` providing the Sentry exporter. +2. `ITracer` impl swaps from `SentryTracer` to `OtelTracer` (uses `@opentelemetry/api`). +3. `ILogger` impl swaps from `SentryLogger` to `OtelLogger` (uses `@opentelemetry/api-logs`, pure OTel Logs API). Sentry receives errors via the OTel log record exporter. Cost: slightly degraded Sentry-native error UX (stack frame normalization, breadcrumb buffer, fingerprinting) in exchange for swap-by-exporter vendor neutrality. +4. New **`IMetrics`** interface added alongside `ITracer`/`ILogger`. Three impls: `NoopMetrics`, `OtelMetrics` (uses metrics API from `@opentelemetry/api`), `RecordingMetrics` (in `core-testing`). Added to `BindContext` as `metrics?` field with a corresponding `MetricsProtocol` in `core-shared/di/bind-protocols.ts`. +5. **OTel auto-instrumentations enabled**: HTTP (`@opentelemetry/instrumentation-http`), undici (`instrumentation-undici`), pg (`instrumentation-pg`). Fetch auto-instrumentation is browser-only and not in scope. +6. **PII scrubbing migrates** from Sentry's `beforeSend`/`beforeSendTransaction` hooks to OTel `SpanProcessor` and `LogRecordProcessor` implementations (`PiiScrubSpanProcessor`, `PiiScrubLogRecordProcessor`). Processors run BEFORE the Sentry exporter in the pipeline, so PII is stripped at the OTel layer regardless of which exporter consumes downstream. The substring list (`PII_SUBSTRINGS` from `pii-fields.ts`) is reused — it's vendor-neutral. +7. **`bindSentryInstrumentation` renamed to `bindOtelInstrumentation`** with a deprecation alias for one release cycle. App aggregators (`apps/web-next/src/server/bind-production.ts` etc.) update their `resolveInstrumentation()` call sites. +8. **Server-only scope.** Browser still uses Sentry SDK directly (init paths in `apps/web-next/instrumentation-client.ts` etc. stay unchanged). Future spec extends to browser if/when OTel-Browser maturity warrants. +9. **Five-phase delivery** matching the established cadence (events, realtime, core-package): + - Phase 1: OTel SDK infrastructure (no behavior swap) + - Phase 2: Tracer swap + - Phase 3: Logger swap + - Phase 4: Metrics introduction + - Phase 5: Auto-instrumentations + PII scrub migration + cleanup + ADR-017 + +## 3. Architecture overview + +| Phase | Ships | Verification | +|---|---|---| +| 1 | OTel SDK init helper (`initOtelServerNode`), resource builder, Sentry-as-exporter bridge module; new `core-shared/src/instrumentation/otel/` subdir; ESLint allowlist extension | OTel SDK boots; Sentry data still flows via direct init | +| 2 | `OtelTracer` impl + tests; `bindSentryInstrumentation` → `bindOtelInstrumentation`; delete `SentryTracer` | Spans reach Sentry via OTel pipeline; `withSpan` call sites unchanged | +| 3 | `OtelLogger` impl + tests (uses OTel Logs API); delete `SentryLogger`; LogRecordProcessor wired into init helper | `captureException` emits OTel log records; double-report guard preserved; PII scrubbed by Sentry's `beforeSend` (temporarily) | +| 4 | `IMetrics` interface + `NoopMetrics` + `OtelMetrics` + `RecordingMetrics`; `BindContext.metrics?` field; `MetricsProtocol` | All three impls pass unit tests; `BindContext` typecheck clean | +| 5 | Auto-instrumentations (HTTP, undici, pg); `PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`; delete Sentry `scrub.ts`; rename `core-testing/setup/no-sentry.ts` → `no-instrumentation.ts`; ADR-017 + ADR-014 status update; doc refreshes | Full PII rules verified at OTel-processor layer; ADR-014's R31-R51 still authoritative for interface; ESLint final allowlist applied | + +## 4. Phase 1 — OTel SDK infrastructure + +### 4.1 Files + +- Create: `packages/core-shared/src/instrumentation/otel/init-server-node.ts` — `initOtelServerNode(opts: { dsn: string; serviceName: string; environment: string; release?: string })` constructs a `NodeSDK` with: resource attributes from `buildResource(...)`, a `BatchSpanProcessor` wrapping the Sentry span exporter, placeholder `LogRecordProcessor` and `MeterProvider` (filled in Phase 3 + 4), placeholder PII scrub processor (filled in Phase 5). Returns the SDK handle; caller is responsible for `sdk.shutdown()` on process exit. +- Create: `packages/core-shared/src/instrumentation/otel/resource.ts` — `buildResource(opts: { serviceName: string; serviceVersion?: string; environment: string; namespace?: string }) => Resource`. Sets `service.name`, `service.version`, `service.namespace`, `deployment.environment` per OTel semantic conventions. +- Create: `packages/core-shared/src/instrumentation/otel/sentry-bridge.ts` — encapsulates Sentry exporter setup. Imports from `@sentry/opentelemetry`. Returns `{ spanProcessor, logRecordProcessor, metricExporter }` ready for registration. Only file in `core-shared` (besides apps' init paths) that imports `@sentry/opentelemetry`. +- Create: `packages/core-shared/src/instrumentation/otel/index.ts` — barrel exporting `initOtelServerNode`, `buildResource`. +- Modify: `packages/core-shared/package.json` — dependencies: `@opentelemetry/api ^1`, `@opentelemetry/sdk-node ^0.x`, `@opentelemetry/resources ^1`, `@opentelemetry/semantic-conventions ^1`, `@opentelemetry/sdk-trace-base ^1`, `@sentry/opentelemetry ^8.x` (peer of `@sentry/node`/`@sentry/nextjs` already in tree). Subpath exports: `./instrumentation/otel`, `./instrumentation/otel/init-server-node`. +- Modify: `packages/core-eslint/base.js` — extend `no-restricted-imports` allowlist (R40, evolving into R52): paths under `**/instrumentation/otel/**` may import OTel SDK packages and `@sentry/opentelemetry`. The `@opentelemetry/api` family — `@opentelemetry/api` (covers traces + metrics) and `@opentelemetry/api-logs` (separate package for logs) — becomes freely importable anywhere in `core-shared/instrumentation/`. + +### 4.2 Constraints + +- Phase 1 does NOT replace `SentryTracer` or `SentryLogger`. Sentry stays the active backend via existing direct init. +- Phase 1 does NOT initialize the OTel SDK from any app. The init helper exists; no app calls it yet (Phase 2 wires it). +- Tests for Phase 1 use OTel's `BasicTracerProvider` + `InMemorySpanExporter` to verify the init helper produces correctly-shaped exporters; no real backend involvement. + +## 5. Phase 2 — Tracer swap + +### 5.1 `OtelTracer` impl + +```ts +import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api"; +import type { ITracer, ISpan, SpanOpts, AttributeValue } from "../tracer.interface"; + +export class OtelTracer implements ITracer { + private readonly tracer = trace.getTracer("@repo/core-shared", "1.0.0"); + + async startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { + const attributes: Record = { + ...(opts.attributes ?? {}), + ...(opts.op ? { "span.op": opts.op } : {}), + }; + + return this.tracer.startActiveSpan( + opts.name, + { kind: SpanKind.INTERNAL, attributes: attributes as Record }, + async (otelSpan) => { + const adapter: ISpan = { + setAttribute(key, value) { if (value !== null) otelSpan.setAttribute(key, value); }, + setStatus(status, message) { + otelSpan.setStatus({ + code: status === "ok" ? SpanStatusCode.OK : SpanStatusCode.ERROR, + message, + }); + }, + }; + try { + return await fn(adapter); + } catch (err) { + otelSpan.recordException(err as Error); + otelSpan.setStatus({ code: SpanStatusCode.ERROR }); + throw err; + } finally { + otelSpan.end(); + } + }, + ); + } +} +``` + +### 5.2 Files + +- Create: `packages/core-shared/src/instrumentation/otel/otel-tracer.ts` (impl above). +- Create: `packages/core-shared/src/instrumentation/otel/otel-tracer.test.ts` (in-memory exporter assertions). +- Rename: `packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts` → `bind-otel-instrumentation.ts`. Function rename: `bindSentryInstrumentation` → `bindOtelInstrumentation`. New impl calls `initOtelServerNode(...)` from Phase 1 before constructing the tracer; binds `OtelTracer` for `ITracer`. +- Modify: `packages/core-shared/src/instrumentation/index.ts` — re-export `bindOtelInstrumentation`; keep `bindSentryInstrumentation` as deprecated alias for one release. +- Modify: `apps/web-next/src/server/bind-production.ts` — `resolveInstrumentation()` calls `bindOtelInstrumentation`. Same in `apps/cms`, `apps/web-tanstack` if applicable. +- Delete: `packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts` and `sentry-tracer.test.ts`. +- Modify: `packages/core-eslint/base.js` — remove `sentry/sentry-tracer.ts` from `@sentry/*` allowlist; add `bind-otel-instrumentation.ts` to the allowlist for `@sentry/opentelemetry` (it still constructs the Sentry bridge). + +### 5.3 Notes + +- `span.op` becomes a regular attribute; `@sentry/opentelemetry` reads it as Sentry's "op" concept when displaying in Sentry UI. +- `recordException` + `setStatus(ERROR)` in catch is OTel-idiomatic; Sentry's exporter consumes this to surface as an error in Sentry UI. +- Double-report guard (`__sentryReported` flag in `reported-flag.ts`) is preserved; works because the flag is on the error object itself, vendor-agnostic. +- `withSpan`/`withCapture` composition (R41-R44) unchanged. + +## 6. Phase 3 — Logger swap + +### 6.1 `OtelLogger` impl + +```ts +import { logs, SeverityNumber } from "@opentelemetry/api-logs"; +import { trace } from "@opentelemetry/api"; +import { isReported, markReported } from "../reported-flag"; +import type { ILogger, Breadcrumb, CaptureContext } from "../logger.interface"; + +export class OtelLogger implements ILogger { + private readonly logger = logs.getLogger("@repo/core-shared", "1.0.0"); + + captureException(err: unknown, ctx?: CaptureContext): void { + if (isReported(err)) return; + markReported(err); + const error = err instanceof Error ? err : new Error(String(err)); + this.logger.emit({ + severityNumber: SeverityNumber.ERROR, + severityText: "ERROR", + body: error.message, + attributes: { + "exception.type": error.name, + "exception.message": error.message, + "exception.stacktrace": error.stack ?? "", + ...flattenTags(ctx?.tags), + ...flattenExtras(ctx?.extras), + ...(ctx?.fingerprint ? { "sentry.fingerprint": ctx.fingerprint.join("|") } : {}), + }, + }); + } + + captureMessage(msg: string, level?: "info" | "warning" | "error", ctx?: CaptureContext): void { + const severityNumber = + level === "error" ? SeverityNumber.ERROR + : level === "warning" ? SeverityNumber.WARN + : SeverityNumber.INFO; + this.logger.emit({ + severityNumber, + severityText: level?.toUpperCase() ?? "INFO", + body: msg, + attributes: { ...flattenTags(ctx?.tags), ...flattenExtras(ctx?.extras) }, + }); + } + + addBreadcrumb(b: Breadcrumb): void { + const span = trace.getActiveSpan(); + if (!span) return; + span.addEvent(b.message, { + "breadcrumb.category": b.category, + "breadcrumb.level": b.level ?? "info", + ...(b.data ? flattenExtras(b.data) : {}), + }); + } + + setUser(user: { id: string } | null): void { + const span = trace.getActiveSpan(); + if (!span) return; + span.setAttribute("user.id", user?.id ?? ""); + } +} + +function flattenTags(tags?: Record) { + return tags ? Object.fromEntries(Object.entries(tags).map(([k, v]) => [`tag.${k}`, v])) : {}; +} +function flattenExtras(extras?: Record) { + if (!extras) return {}; + return Object.fromEntries( + Object.entries(extras).map(([k, v]) => [`extra.${k}`, typeof v === "string" ? v : JSON.stringify(v)]), + ); +} +``` + +### 6.2 Files + +- Create: `packages/core-shared/src/instrumentation/otel/otel-logger.ts` (impl above). +- Create: `packages/core-shared/src/instrumentation/otel/otel-logger.test.ts` (in-memory log record exporter assertions). +- Modify: `packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts` — bind `OtelLogger` for `ILogger`. +- Modify: `packages/core-shared/src/instrumentation/otel/init-server-node.ts` — register a `BatchLogRecordProcessor` wrapping the Sentry log exporter from Phase 1's bridge. +- Delete: `packages/core-shared/src/instrumentation/sentry/sentry-logger.ts` and test. +- Modify: `packages/core-eslint/base.js` — narrow `@sentry/*` allowlist (remove `sentry/sentry-logger.ts`). +- Modify: `packages/core-shared/package.json` — add `@opentelemetry/api-logs ^0.x`, `@opentelemetry/sdk-logs ^0.x`. + +### 6.3 Semantic shifts + +- **Breadcrumbs → span events.** Native OTel pattern. Sentry's exporter surfaces span events as breadcrumbs in the UI. Some richness lost: native Sentry breadcrumbs span across multiple operations (a buffer cleared only on event); OTel span events are per-span. Acceptable for vendor-neutrality goal. +- **`setUser` per-span vs scope-global.** Sentry's `setUser` is scope-global; OTel attaches user as span attribute. Children inherit via OTel context. For request-scoped flows (the typical case), behavior matches. For long-lived background tasks, set explicitly on the relevant span. +- **R36 preserved.** `user.id` only; no email/username. + +### 6.4 Severity map + +| `ILogger` level | OTel `SeverityNumber` | `severityText` | +|---|---|---| +| (captureException) | `ERROR` (17) | `"ERROR"` | +| `"error"` | `ERROR` (17) | `"ERROR"` | +| `"warning"` | `WARN` (13) | `"WARN"` | +| `"info"` / default | `INFO` (9) | `"INFO"` | + +## 7. Phase 4 — Metrics introduction + +### 7.1 `IMetrics` interface + +```ts +export type MetricAttributeValue = string | number | boolean; + +export interface IMetrics { + /** Monotonic counter. Use for event counts (signups, errors, requests). */ + counter(name: string, value?: number, attributes?: Record): void; + /** Distribution. Use for measured quantities (latency, payload size). */ + histogram(name: string, value: number, attributes?: Record): void; + /** Point-in-time value. UpDownCounter under the hood — true gauge semantics require observable async via a future v2. */ + gauge(name: string, value: number, attributes?: Record): void; +} +``` + +### 7.2 Files + +- Create: `packages/core-shared/src/instrumentation/metrics.interface.ts` (interface above). +- Create: `packages/core-shared/src/instrumentation/noop-metrics.ts` + test. +- Create: `packages/core-shared/src/instrumentation/otel/otel-metrics.ts` + test. +- Create: `packages/core-testing/src/instrumentation/recording-metrics.ts` + test. +- Modify: `packages/core-shared/src/instrumentation/index.ts` — re-export `IMetrics`, `NoopMetrics`. +- Modify: `packages/core-shared/src/instrumentation/symbols.ts` — add `INSTRUMENTATION_SYMBOLS.IMetrics`. +- Modify: `packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts` — bind `NoopMetrics`. +- Modify: `packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts` — bind `OtelMetrics`; wire `MeterProvider` with Sentry metric exporter into `initOtelServerNode`. +- Modify: `packages/core-shared/src/di/bind-protocols.ts` — add `MetricsProtocol`. +- Modify: `packages/core-shared/src/di/bind-context.ts` — extend `BindContext` with `metrics?: Metrics` field. `IMetrics extends MetricsProtocol` (one-line change in metrics.interface.ts). +- Modify: `apps/web-next/src/server/bind-production.ts` — generic args broaden to include `IMetrics` when present. +- Modify: `packages/core-shared/package.json` — add `@opentelemetry/sdk-metrics ^0.x`. (Metrics API lives in `@opentelemetry/api` from Phase 1; no separate `@opentelemetry/api-metrics` package needed in modern OTel JS.) + +### 7.3 Notes + +- `gauge` uses `UpDownCounter` under the hood (synchronous emit). True "set" semantics require `ObservableGauge` with periodic callback — deferred to a v2 spec when first true-gauge use case lands. +- No feature call sites added in Phase 4. Three impls + `BindContext.metrics?` available for opportunistic per-feature adoption. +- `RecordingMetrics` follows the same vendor-agnostic pattern as `RecordingTracer`/`RecordingLogger` — no OTel or Sentry imports. + +## 8. Phase 5 — Auto-instrumentations + PII scrubbing + cleanup + +### 8.1 Auto-instrumentations + +Register in `init-server-node.ts`: + +```ts +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici"; +import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; +import { registerInstrumentations } from "@opentelemetry/instrumentation"; + +registerInstrumentations({ + instrumentations: [ + new HttpInstrumentation({ + requestHook: (span, request) => { + const url = request.url ?? ""; + span.setAttribute("http.url.path", url.split("?")[0]); + }, + ignoreIncomingRequestHook: (req) => req.url === "/_health" || req.url === "/_otel-export", + }), + new UndiciInstrumentation(), + new PgInstrumentation({ enhancedDatabaseReporting: false }), + ], +}); +``` + +`@opentelemetry/instrumentation-fetch` is browser-only — not in this migration's scope. Undici covers Node's native fetch. + +### 8.2 PII scrubbing migration + +```ts +// packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts +import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base"; +import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { PII_SUBSTRINGS } from "./pii-fields"; + +function scrubAttributes(attrs: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(attrs)) { + const isPiiKey = PII_SUBSTRINGS.some((s) => key.toLowerCase().includes(s)); + out[key] = isPiiKey ? "[redacted]" : value; + } + return out; +} + +export class PiiScrubSpanProcessor implements SpanProcessor { + forceFlush() { return Promise.resolve(); } + shutdown() { return Promise.resolve(); } + onStart(): void { /* no-op */ } + onEnd(span: ReadableSpan): void { + Object.assign(span.attributes, scrubAttributes(span.attributes as Record)); + } +} + +export class PiiScrubLogRecordProcessor implements LogRecordProcessor { + forceFlush() { return Promise.resolve(); } + shutdown() { return Promise.resolve(); } + onEmit(record: LogRecord): void { + if (record.attributes) { + record.attributes = scrubAttributes(record.attributes as Record); + } + if (typeof record.body === "string") { + const bodyContainsPii = PII_SUBSTRINGS.some((s) => record.body!.toString().toLowerCase().includes(s)); + if (bodyContainsPii) record.body = "[redacted]"; + } + } +} +``` + +Both processors registered FIRST in the OTel SDK's processor chains in `init-server-node.ts`: + +```ts +const sdk = new NodeSDK({ + resource, + spanProcessors: [ + new PiiScrubSpanProcessor(), + new BatchSpanProcessor(sentrySpanExporter), + ], + logRecordProcessors: [ + new PiiScrubLogRecordProcessor(), + new BatchLogRecordProcessor(sentryLogExporter), + ], +}); +``` + +`onStart`/`onEnd` ordering guarantees PII is scrubbed before downstream processors (including the Sentry exporter) see attributes. Sentry's `beforeSend`/`beforeSendTransaction` hooks are removed (they were a Sentry-specific belt-and-suspenders). + +### 8.3 Cleanup + +- Move: `packages/core-shared/src/instrumentation/sentry/pii-fields.ts` → `packages/core-shared/src/instrumentation/otel/pii-fields.ts`. Update imports. +- Delete: `packages/core-shared/src/instrumentation/sentry/scrub.ts`, `scrub.test.ts`. +- Delete: `packages/core-shared/src/instrumentation/sentry/` directory entirely. The `@sentry/opentelemetry` bridge in `otel/sentry-bridge.ts` is the only remaining Sentry-coupled code in `core-shared`. +- Modify: `packages/core-shared/src/instrumentation/index.ts` — remove all `./sentry/*` re-exports. +- Modify: `packages/core-shared/package.json` — drop `./instrumentation/sentry/*` subpath exports; keep `./instrumentation/otel/*` from earlier phases. +- Modify: `packages/core-eslint/base.js` — final allowlist shape: + - `@sentry/*` allowed only in: `**/instrumentation/otel/sentry-bridge.ts`, app-level `instrumentation*.{ts,mjs}` and `next.config.{mjs}` / `vite.config.{ts}` entries. + - `@opentelemetry/api` (covers traces + metrics) and `@opentelemetry/api-logs` (separate package, logs only) — free anywhere in `core-shared/instrumentation/`. + - `@opentelemetry/sdk-*`, `exporter-*`, `instrumentation-*`, `resources`, `semantic-conventions` — restricted to `**/instrumentation/otel/**` and app init paths. +- Rename: `packages/core-testing/src/setup/no-sentry.ts` → `no-instrumentation.ts`; mocks both `@sentry/*` and `@opentelemetry/sdk-*` modules. Keep old name as deprecated alias for one release. +- Create: `docs/decisions/adr-017-opentelemetry-migration.md` — supersedes ADR-014's impl section. +- Modify: `docs/decisions/adr-014-instrumentation-sentry.md` — Status header: "Superseded by ADR-017 for the impl layer; interface decisions R31-R51 remain authoritative." +- Modify: `CLAUDE.md`, `AGENTS.md`, `docs/architecture/dependency-flow.md`, `docs/architecture/vertical-feature-spec.md`, HTML explainers — references to "Sentry SDK" updated to "OTel SDK with Sentry as one of N exporters" where relevant. + +## 9. Testing strategy + +Three layers across phases: + +1. **Unit tests per impl** — each new file gets a Vitest test using OTel's in-memory exporters (`InMemorySpanExporter`, `InMemoryLogRecordExporter`, `InMemoryMetricExporter`) to assert observable behavior. No real backend involvement. +2. **Recording test doubles** (`RecordingTracer`, `RecordingLogger`, `RecordingMetrics` in `core-testing/instrumentation/`) stay vendor-agnostic. Feature tests use them via direct injection (existing R27). +3. **Integration test (end of Phase 5)** — single test that constructs the full OTel SDK with PII scrub processors + in-memory exporter; emits a span with PII-laden attributes; asserts the exporter receives scrubbed output. Proves processor ordering. + +Each phase preserves all existing tests: `webNext bind-production.test.ts` (currently 24 tests), feature `bind-dev-seed.test.ts` files, and the existing Sentry-impl tests deleted only when the impl they cover is deleted in the same phase. + +Manual smoke (Phase 3 + 5): start `pnpm dev` with `WEB_NEXT_SENTRY_DSN` set; trigger a tRPC call that exercises a span + an error; visually confirm both arrive in Sentry UI via the OTel exporter pipeline. + +## 10. ESLint rule evolution + +Current ADR-014 R40 blocks `@sentry/*` outside specific paths. The migration extends this to: + +- **R40 (existing, narrowed):** `@sentry/*` allowed only in `**/instrumentation/otel/sentry-bridge.ts` (the OTel-Sentry bridge), app-level `instrumentation*.{ts,mjs}` / `next.config.{mjs}` / `vite.config.{ts}` entries. +- **R52 (new):** `@opentelemetry/sdk-*`, `@opentelemetry/exporter-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions` — restricted to `**/instrumentation/otel/**` and app init paths. +- **No restriction:** `@opentelemetry/api` (traces + metrics) and `@opentelemetry/api-logs` (logs) are the vendor-neutral surface; any path in `core-shared/instrumentation/` may import them. + +CI grep gates from ADR-014 carry over: `sendDefaultPii` check now applies at the OTel resource level (the bridge sets the option on the Sentry exporter wrapper). + +## 11. Out of scope + +- **Browser-side migration.** Sentry's browser SDK (replay, native error UX) stays. A separate spec can extend OTel to the browser when OTel-Browser maturity warrants. +- **Replacing Sentry as the exporter.** This spec wires Sentry as the only exporter today. The whole point of the design is exporter pluggability — adding Honeycomb / Datadog / Grafana exporters later is a small follow-up. +- **`ObservableGauge` async metrics.** Synchronous `UpDownCounter` is the stand-in. True async gauges require a callback-driven shape; deferred to a v2 metrics interface. +- **OTel Collector deployment.** Direct SDK-to-Sentry-exporter wiring is sufficient for current scale. Adding an OTel Collector (separate process or sidecar) is a future scaling decision. +- **Metrics call sites in features.** Phase 4 ships the interface + impls + `BindContext.metrics?`; per-feature instrumentation is opportunistic. + +## 12. Related + +- ADR-014 — instrumentation + Sentry (interface decisions R31-R51 remain authoritative; impl section superseded by ADR-017) +- ADR-015 — events and jobs (`MetricsProtocol` follows the same protocol-in-core-shared pattern) +- ADR-016 — realtime layer (parallel pattern: interface in must-have, impl in optional package) +- `core-shared/instrumentation/sentry/` directory — fully removed by Phase 5; `otel/sentry-bridge.ts` is the only remaining Sentry-coupled file