# 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<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
    const attributes: Record<string, AttributeValue> = {
      ...(opts.attributes ?? {}),
      ...(opts.op ? { "span.op": opts.op } : {}),
    };

    return this.tracer.startActiveSpan(
      opts.name,
      { kind: SpanKind.INTERNAL, attributes: attributes as Record<string, string | number | boolean> },
      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<string, string>) {
  return tags ? Object.fromEntries(Object.entries(tags).map(([k, v]) => [`tag.${k}`, v])) : {};
}
function flattenExtras(extras?: Record<string, unknown>) {
  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<string, MetricAttributeValue>): void;
  /** Distribution. Use for measured quantities (latency, payload size). */
  histogram(name: string, value: number, attributes?: Record<string, MetricAttributeValue>): 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<string, MetricAttributeValue>): 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<Bus, Realtime, RealtimeReg, Metrics extends MetricsProtocol = MetricsProtocol>` 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<string, unknown>): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  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<string, unknown>));
  }
}

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<string, unknown>);
    }
    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
