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>
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
- Migrate server-side instrumentation from Sentry-direct SDK calls to OpenTelemetry SDK with
@sentry/opentelemetryproviding the Sentry exporter. ITracerimpl swaps fromSentryTracertoOtelTracer(uses@opentelemetry/api).ILoggerimpl swaps fromSentryLoggertoOtelLogger(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.- New
IMetricsinterface added alongsideITracer/ILogger. Three impls:NoopMetrics,OtelMetrics(uses metrics API from@opentelemetry/api),RecordingMetrics(incore-testing). Added toBindContextasmetrics?field with a correspondingMetricsProtocolincore-shared/di/bind-protocols.ts. - OTel auto-instrumentations enabled: HTTP (
@opentelemetry/instrumentation-http), undici (instrumentation-undici), pg (instrumentation-pg). Fetch auto-instrumentation is browser-only and not in scope. - PII scrubbing migrates from Sentry's
beforeSend/beforeSendTransactionhooks to OTelSpanProcessorandLogRecordProcessorimplementations (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_SUBSTRINGSfrompii-fields.ts) is reused — it's vendor-neutral. bindSentryInstrumentationrenamed tobindOtelInstrumentationwith a deprecation alias for one release cycle. App aggregators (apps/web-next/src/server/bind-production.tsetc.) update theirresolveInstrumentation()call sites.- Server-only scope. Browser still uses Sentry SDK directly (init paths in
apps/web-next/instrumentation-client.tsetc. stay unchanged). Future spec extends to browser if/when OTel-Browser maturity warrants. - 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 aNodeSDKwith: resource attributes frombuildResource(...), aBatchSpanProcessorwrapping the Sentry span exporter, placeholderLogRecordProcessorandMeterProvider(filled in Phase 3 + 4), placeholder PII scrub processor (filled in Phase 5). Returns the SDK handle; caller is responsible forsdk.shutdown()on process exit. - Create:
packages/core-shared/src/instrumentation/otel/resource.ts—buildResource(opts: { serviceName: string; serviceVersion?: string; environment: string; namespace?: string }) => Resource. Setsservice.name,service.version,service.namespace,deployment.environmentper 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 incore-shared(besides apps' init paths) that imports@sentry/opentelemetry. - Create:
packages/core-shared/src/instrumentation/otel/index.ts— barrel exportinginitOtelServerNode,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/nextjsalready in tree). Subpath exports:./instrumentation/otel,./instrumentation/otel/init-server-node. - Modify:
packages/core-eslint/base.js— extendno-restricted-importsallowlist (R40, evolving into R52): paths under**/instrumentation/otel/**may import OTel SDK packages and@sentry/opentelemetry. The@opentelemetry/apifamily —@opentelemetry/api(covers traces + metrics) and@opentelemetry/api-logs(separate package for logs) — becomes freely importable anywhere incore-shared/instrumentation/.
4.2 Constraints
- Phase 1 does NOT replace
SentryTracerorSentryLogger. 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+InMemorySpanExporterto 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.ts→bind-otel-instrumentation.ts. Function rename:bindSentryInstrumentation→bindOtelInstrumentation. New impl callsinitOtelServerNode(...)from Phase 1 before constructing the tracer; bindsOtelTracerforITracer. - Modify:
packages/core-shared/src/instrumentation/index.ts— re-exportbindOtelInstrumentation; keepbindSentryInstrumentationas deprecated alias for one release. - Modify:
apps/web-next/src/server/bind-production.ts—resolveInstrumentation()callsbindOtelInstrumentation. Same inapps/cms,apps/web-tanstackif applicable. - Delete:
packages/core-shared/src/instrumentation/sentry/sentry-tracer.tsandsentry-tracer.test.ts. - Modify:
packages/core-eslint/base.js— removesentry/sentry-tracer.tsfrom@sentry/*allowlist; addbind-otel-instrumentation.tsto the allowlist for@sentry/opentelemetry(it still constructs the Sentry bridge).
5.3 Notes
span.opbecomes a regular attribute;@sentry/opentelemetryreads 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 (
__sentryReportedflag inreported-flag.ts) is preserved; works because the flag is on the error object itself, vendor-agnostic. withSpan/withCapturecomposition (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— bindOtelLoggerforILogger. - Modify:
packages/core-shared/src/instrumentation/otel/init-server-node.ts— register aBatchLogRecordProcessorwrapping the Sentry log exporter from Phase 1's bridge. - Delete:
packages/core-shared/src/instrumentation/sentry/sentry-logger.tsand test. - Modify:
packages/core-eslint/base.js— narrow@sentry/*allowlist (removesentry/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.
setUserper-span vs scope-global. Sentry'ssetUseris 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.idonly; 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-exportIMetrics,NoopMetrics. - Modify:
packages/core-shared/src/instrumentation/symbols.ts— addINSTRUMENTATION_SYMBOLS.IMetrics. - Modify:
packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts— bindNoopMetrics. - Modify:
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts— bindOtelMetrics; wireMeterProviderwith Sentry metric exporter intoinitOtelServerNode. - Modify:
packages/core-shared/src/di/bind-protocols.ts— addMetricsProtocol. - Modify:
packages/core-shared/src/di/bind-context.ts— extendBindContext<Bus, Realtime, RealtimeReg, Metrics extends MetricsProtocol = MetricsProtocol>withmetrics?: Metricsfield.IMetrics extends MetricsProtocol(one-line change in metrics.interface.ts). - Modify:
apps/web-next/src/server/bind-production.ts— generic args broaden to includeIMetricswhen present. - Modify:
packages/core-shared/package.json— add@opentelemetry/sdk-metrics ^0.x. (Metrics API lives in@opentelemetry/apifrom Phase 1; no separate@opentelemetry/api-metricspackage needed in modern OTel JS.)
7.3 Notes
gaugeusesUpDownCounterunder the hood (synchronous emit). True "set" semantics requireObservableGaugewith 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. RecordingMetricsfollows the same vendor-agnostic pattern asRecordingTracer/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.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/opentelemetrybridge inotel/sentry-bridge.tsis the only remaining Sentry-coupled code incore-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-levelinstrumentation*.{ts,mjs}andnext.config.{mjs}/vite.config.{ts}entries.@opentelemetry/api(covers traces + metrics) and@opentelemetry/api-logs(separate package, logs only) — free anywhere incore-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:
- 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. - Recording test doubles (
RecordingTracer,RecordingLogger,RecordingMetricsincore-testing/instrumentation/) stay vendor-agnostic. Feature tests use them via direct injection (existing R27). - 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-levelinstrumentation*.{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 incore-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.
ObservableGaugeasync metrics. SynchronousUpDownCounteris 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 (
MetricsProtocolfollows 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.tsis the only remaining Sentry-coupled file