Files
agentic-dev/docs/superpowers/specs/2026-05-11-opentelemetry-migration-design.md
Danijel Martinek cb0e33b0e8 docs(spec): OpenTelemetry migration design
Five-phase migration of server-side instrumentation from Sentry-direct
SDK calls to OpenTelemetry SDK with @sentry/opentelemetry as the (initial)
exporter. Vendor neutrality: swap vendors = swap exporters, never rewrite
tracer/logger/metrics impls.

Phase 1: OTel SDK infrastructure (no behavior swap).
Phase 2: OtelTracer replaces SentryTracer (uses @opentelemetry/api).
Phase 3: OtelLogger replaces SentryLogger (pure OTel Logs API via
  @opentelemetry/api-logs; breadcrumbs become span events; user.id as
  span attribute).
Phase 4: New IMetrics interface + Noop/Otel/Recording impls; added to
  BindContext as metrics? with corresponding MetricsProtocol in
  core-shared/di/bind-protocols.ts.
Phase 5: HTTP/undici/pg auto-instrumentations; PII scrubbing migrates
  from Sentry beforeSend hooks to OTel SpanProcessor/LogRecordProcessor;
  delete remaining Sentry-direct files; ADR-017 supersedes ADR-014's
  impl section while keeping R31-R51 interface decisions authoritative.

Server-only scope. Browser keeps Sentry SDK directly (replay + native
error UX); future spec extends to browser when OTel-Browser matures.

Companion ADR will be assigned at implementation time (expected ADR-017).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:04:37 +02:00

27 KiB

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; bindSentryInstrumentationbindOtelInstrumentation; 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.tsno-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.tsinitOtelServerNode(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.tsbuildResource(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

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.tsbind-otel-instrumentation.ts. Function rename: bindSentryInstrumentationbindOtelInstrumentation. 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.tsresolveInstrumentation() 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

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

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:

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

// 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:

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.tspackages/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.tsno-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.
  • 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