# 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