Implementation plan for the spec at docs/superpowers/specs/2026-05-11- opentelemetry-migration-design.md. Five phases: - Phase 1: OTel SDK infrastructure (resource builder + Sentry-as-exporter bridge + NodeSDK init helper + ESLint allowlist for OTel SDK packages). No behavior swap yet. - Phase 2 (TDD): OtelTracer impl using @opentelemetry/api; bind-sentry- instrumentation renamed to bind-otel-instrumentation with deprecation alias; delete SentryTracer. - Phase 3 (TDD): OtelLogger impl using @opentelemetry/api-logs; LogRecordProcessor wired into init helper; delete SentryLogger. Breadcrumbs become span events; setUser sets user.id span attribute. - Phase 4 (TDD): New IMetrics interface + Noop/Otel/Recording impls; MetricsProtocol added to bind-protocols; BindContext.metrics? optional field. Sentry metrics exporter deferred (experimental). - Phase 5 (TDD): HTTP/undici/pg auto-instrumentations; PII scrub processors (PiiScrubSpanProcessor + PiiScrubLogRecordProcessor) run FIRST in OTel pipeline; delete sentry/scrub.ts + orphaned init files; core-testing/setup/no-sentry.ts → no-instrumentation.ts (mocks both Sentry and OTel SDK); ADR-017 + ADR-014 status header; doc refreshes. Total: ~30 tasks across 6 phases (including Phase 0 read-first), ~25 expected commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
88 KiB
OpenTelemetry Migration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Migrate server-side instrumentation from Sentry-direct SDK calls to OpenTelemetry SDK with @sentry/opentelemetry as the exporter, add a new IMetrics signal, enable HTTP/undici/pg auto-instrumentations, and move PII scrubbing from Sentry's beforeSend hooks to OTel SpanProcessor/LogRecordProcessor implementations.
Architecture: Five sequential phases. Phase 1 lays OTel SDK infrastructure with Sentry as an exporter (no behavior swap yet). Phases 2-4 swap ITracer → OtelTracer, ILogger → OtelLogger, and introduce IMetrics + OtelMetrics. Phase 5 enables auto-instrumentations, moves PII scrubbing to OTel processors, deletes remaining Sentry-direct files, and publishes ADR-017. Browser keeps Sentry SDK directly (out of scope).
Tech Stack: TypeScript, Node 22, OpenTelemetry JS SDK (@opentelemetry/api, @opentelemetry/api-logs, @opentelemetry/sdk-node, @opentelemetry/sdk-trace-base, @opentelemetry/sdk-logs, @opentelemetry/sdk-metrics, @opentelemetry/instrumentation-http, @opentelemetry/instrumentation-undici, @opentelemetry/instrumentation-pg), @sentry/opentelemetry, Vitest.
Spec: docs/superpowers/specs/2026-05-11-opentelemetry-migration-design.md — read first, especially §4–§8 (per-phase detail) and §10 (ESLint rule evolution).
Phase numbering: Plan uses Phase 0 (Read first) as preamble, so plan phases shift up by one from the spec. Mapping:
| Plan | Spec |
|---|---|
| Phase 0 (Read first) | (orientation; not in spec) |
| Phase 1 (OTel infrastructure) | Spec §4 |
| Phase 2 (Tracer swap) | Spec §5 |
| Phase 3 (Logger swap) | Spec §6 |
| Phase 4 (Metrics introduction) | Spec §7 |
| Phase 5 (Auto-instrumentations + PII + cleanup) | Spec §8 |
Phase 0 — Read first
- Step 1: Read the spec end-to-end
Open docs/superpowers/specs/2026-05-11-opentelemetry-migration-design.md. Pay close attention to §4 (Phase 1 init helper), §5 (OtelTracer shape), §6 (OtelLogger shape), §7 (IMetrics interface + impls), §8.2 (PII scrub processors), §10 (ESLint allowlist evolution).
- Step 2: Read ADR-014
Open docs/decisions/adr-014-instrumentation-sentry.md. The interface decisions (R31–R51) carry over unchanged. The implementation section is what this migration supersedes.
- Step 3: Skim current Sentry impls
Read these files end-to-end:
packages/core-shared/src/instrumentation/tracer.interface.tspackages/core-shared/src/instrumentation/logger.interface.tspackages/core-shared/src/instrumentation/sentry/sentry-tracer.tspackages/core-shared/src/instrumentation/sentry/sentry-logger.tspackages/core-shared/src/instrumentation/sentry/scrub.tspackages/core-shared/src/instrumentation/sentry/pii-fields.tspackages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.tspackages/core-shared/src/instrumentation/di/bind-noop-instrumentation.tspackages/core-shared/src/instrumentation/with-span.tspackages/core-shared/src/instrumentation/with-capture.tspackages/core-shared/src/instrumentation/reported-flag.tspackages/core-eslint/base.js(specifically the@sentry/*allowlist rule block, R40)apps/web-next/src/server/bind-production.ts— seeresolveInstrumentation()packages/core-testing/src/setup/no-sentry.ts
Phase 1 — OTel SDK infrastructure
Goal: Ship OTel SDK boot machinery and the Sentry-as-exporter bridge. Sentry stays the active backend via existing direct init. No behavior swap.
Files touched:
- Create:
packages/core-shared/src/instrumentation/otel/resource.ts - Create:
packages/core-shared/src/instrumentation/otel/resource.test.ts - Create:
packages/core-shared/src/instrumentation/otel/sentry-bridge.ts - Create:
packages/core-shared/src/instrumentation/otel/sentry-bridge.test.ts - Create:
packages/core-shared/src/instrumentation/otel/init-server-node.ts - Create:
packages/core-shared/src/instrumentation/otel/init-server-node.test.ts - Create:
packages/core-shared/src/instrumentation/otel/index.ts - Modify:
packages/core-shared/package.json - Modify:
packages/core-eslint/base.js
Task 1.1: Add OTel SDK dependencies
Files:
-
Modify:
packages/core-shared/package.json -
Step 1: Read current
core-shared/package.json
cat packages/core-shared/package.json
Note the existing dependencies block and exports block.
- Step 2: Add OTel + Sentry-OTel deps
Update packages/core-shared/package.json's dependencies section to add:
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/resources": "^1.27.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/sdk-trace-base": "^1.27.0",
"@opentelemetry/semantic-conventions": "^1.27.0",
"@sentry/opentelemetry": "^8.40.0"
Place them alphabetically among existing deps. Note: @sentry/opentelemetry version should match the @sentry/nextjs version already in tree (check the version pinned in apps/web-next/package.json or apps/cms/package.json).
- Step 3: Add subpath exports
In packages/core-shared/package.json exports block, add (alphabetically placed):
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
"./instrumentation/otel/init-server-node": "./src/instrumentation/otel/init-server-node.ts"
- Step 4: Install
pnpm install
Expected: dependencies resolve cleanly. No errors.
- Step 5: Commit
git add packages/core-shared/package.json pnpm-lock.yaml
git commit -m "feat(core-shared): add OpenTelemetry SDK dependencies"
Task 1.2: Resource builder (TDD)
Files:
-
Create:
packages/core-shared/src/instrumentation/otel/resource.ts -
Create:
packages/core-shared/src/instrumentation/otel/resource.test.ts -
Step 1: Write the failing test
Create packages/core-shared/src/instrumentation/otel/resource.test.ts:
import { describe, it, expect } from "vitest";
import { buildResource } from "./resource";
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, ATTR_DEPLOYMENT_ENVIRONMENT_NAME }
from "@opentelemetry/semantic-conventions/incubating";
describe("buildResource", () => {
it("populates service name, version, and environment", () => {
const r = buildResource({
serviceName: "web-next",
serviceVersion: "1.0.0",
environment: "production",
});
expect(r.attributes[ATTR_SERVICE_NAME]).toBe("web-next");
expect(r.attributes[ATTR_SERVICE_VERSION]).toBe("1.0.0");
expect(r.attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]).toBe("production");
});
it("populates namespace when provided", () => {
const r = buildResource({
serviceName: "web-next",
environment: "production",
namespace: "template-vertical",
});
expect(r.attributes["service.namespace"]).toBe("template-vertical");
});
it("omits version and namespace when not provided", () => {
const r = buildResource({
serviceName: "web-next",
environment: "production",
});
expect(r.attributes[ATTR_SERVICE_VERSION]).toBeUndefined();
expect(r.attributes["service.namespace"]).toBeUndefined();
});
});
- Step 2: Run test → FAIL
pnpm --filter @repo/core-shared test resource.test
Expected: FAIL — ./resource not found.
- Step 3: Implement
Create packages/core-shared/src/instrumentation/otel/resource.ts:
import { resourceFromAttributes, type Resource } from "@opentelemetry/resources";
export type BuildResourceOpts = {
serviceName: string;
serviceVersion?: string;
environment: string;
namespace?: string;
};
/**
* Builds an OpenTelemetry Resource with semantic-convention attributes.
* Each app constructs its own resource at startup (per-app service name).
*/
export function buildResource(opts: BuildResourceOpts): Resource {
const attrs: Record<string, string> = {
"service.name": opts.serviceName,
"deployment.environment.name": opts.environment,
};
if (opts.serviceVersion) attrs["service.version"] = opts.serviceVersion;
if (opts.namespace) attrs["service.namespace"] = opts.namespace;
return resourceFromAttributes(attrs);
}
- Step 4: Run test → PASS
pnpm --filter @repo/core-shared test resource.test
Expected: PASS, 3 tests.
- Step 5: Commit
git add packages/core-shared/src/instrumentation/otel/resource.ts \
packages/core-shared/src/instrumentation/otel/resource.test.ts
git commit -m "feat(core-shared): OTel resource builder"
Task 1.3: Sentry-OTel bridge (TDD)
Files:
-
Create:
packages/core-shared/src/instrumentation/otel/sentry-bridge.ts -
Create:
packages/core-shared/src/instrumentation/otel/sentry-bridge.test.ts -
Step 1: Write the failing test
Create packages/core-shared/src/instrumentation/otel/sentry-bridge.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
beforeEach(() => vi.resetModules());
describe("createSentryOtelBridge", () => {
it("returns a span processor and log record processor when given a DSN", async () => {
// Mock @sentry/opentelemetry — we only verify the shape of what the bridge returns.
vi.doMock("@sentry/opentelemetry", () => ({
SentrySpanProcessor: class { onStart() {} onEnd() {} forceFlush() { return Promise.resolve(); } shutdown() { return Promise.resolve(); } },
SentryLogRecordProcessor: class { onEmit() {} forceFlush() { return Promise.resolve(); } shutdown() { return Promise.resolve(); } },
}));
const { createSentryOtelBridge } = await import("./sentry-bridge");
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
expect(bridge.spanProcessor).toBeDefined();
expect(bridge.logRecordProcessor).toBeDefined();
});
it("returns null processors when no DSN provided", async () => {
const { createSentryOtelBridge } = await import("./sentry-bridge");
const bridge = createSentryOtelBridge({ dsn: "" });
expect(bridge.spanProcessor).toBeNull();
expect(bridge.logRecordProcessor).toBeNull();
});
});
- Step 2: Run test → FAIL
pnpm --filter @repo/core-shared test sentry-bridge.test
Expected: FAIL — module not found.
- Step 3: Implement
Create packages/core-shared/src/instrumentation/otel/sentry-bridge.ts:
import type { SpanProcessor } from "@opentelemetry/sdk-trace-base";
import type { LogRecordProcessor } from "@opentelemetry/sdk-logs";
export type SentryOtelBridgeOpts = {
/** Sentry DSN. When empty, no Sentry processors are returned (Noop boot). */
dsn: string;
};
export type SentryOtelBridge = {
spanProcessor: SpanProcessor | null;
logRecordProcessor: LogRecordProcessor | null;
};
/**
* Creates Sentry-as-OTel-exporter processors. The OTel SDK uses these to
* forward spans and log records to Sentry. This is the ONLY file in
* core-shared that imports from `@sentry/opentelemetry` — all other Sentry
* coupling is excluded by the R40/R52 ESLint allowlist.
*/
export function createSentryOtelBridge(opts: SentryOtelBridgeOpts): SentryOtelBridge {
if (!opts.dsn) {
return { spanProcessor: null, logRecordProcessor: null };
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const sentryOtel = require("@sentry/opentelemetry");
return {
spanProcessor: new sentryOtel.SentrySpanProcessor(),
logRecordProcessor: new sentryOtel.SentryLogRecordProcessor(),
};
}
- Step 4: Run test → PASS
pnpm --filter @repo/core-shared test sentry-bridge.test
Expected: PASS, 2 tests.
- Step 5: Commit
git add packages/core-shared/src/instrumentation/otel/sentry-bridge.ts \
packages/core-shared/src/instrumentation/otel/sentry-bridge.test.ts
git commit -m "feat(core-shared): Sentry-as-OTel-exporter bridge module"
Task 1.4: OTel SDK init helper (TDD)
Files:
-
Create:
packages/core-shared/src/instrumentation/otel/init-server-node.ts -
Create:
packages/core-shared/src/instrumentation/otel/init-server-node.test.ts -
Step 1: Write the failing test
Create packages/core-shared/src/instrumentation/otel/init-server-node.test.ts:
import { describe, it, expect } from "vitest";
import { initOtelServerNode } from "./init-server-node";
describe("initOtelServerNode", () => {
it("returns an SDK handle with shutdown()", () => {
const sdk = initOtelServerNode({
dsn: "",
serviceName: "test-service",
environment: "test",
});
expect(sdk).toBeDefined();
expect(typeof sdk.shutdown).toBe("function");
});
it("accepts a DSN and wires the Sentry bridge", () => {
const sdk = initOtelServerNode({
dsn: "https://test@sentry.io/1",
serviceName: "test-service",
environment: "test",
});
expect(sdk).toBeDefined();
});
});
- Step 2: Run test → FAIL
pnpm --filter @repo/core-shared test init-server-node.test
Expected: FAIL — module not found.
- Step 3: Implement
Create packages/core-shared/src/instrumentation/otel/init-server-node.ts:
import { NodeSDK } from "@opentelemetry/sdk-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { buildResource } from "./resource";
import { createSentryOtelBridge } from "./sentry-bridge";
export type InitOtelServerNodeOpts = {
/** Sentry DSN. When empty, OTel SDK boots without the Sentry exporter. */
dsn: string;
serviceName: string;
serviceVersion?: string;
environment: string;
namespace?: string;
};
/**
* Initializes the OpenTelemetry NodeSDK for a server-side app.
* - Configures Resource attributes per OTel semantic conventions.
* - Registers Sentry processors (via createSentryOtelBridge) when DSN is set.
* - PII scrub processors land in Phase 5; LogRecordProcessor + MeterProvider
* are placeholder slots filled by Phase 3 and Phase 4 respectively.
*
* Caller is responsible for `sdk.shutdown()` on process exit.
*/
export function initOtelServerNode(opts: InitOtelServerNodeOpts): NodeSDK {
const resource = buildResource({
serviceName: opts.serviceName,
serviceVersion: opts.serviceVersion,
environment: opts.environment,
namespace: opts.namespace,
});
const bridge = createSentryOtelBridge({ dsn: opts.dsn });
const spanProcessors = bridge.spanProcessor
? [new BatchSpanProcessor(bridge.spanProcessor as never)]
: [];
const sdk = new NodeSDK({
resource,
spanProcessors,
// logRecordProcessors filled in Phase 3
// metricReader filled in Phase 4
});
sdk.start();
return sdk;
}
- Step 4: Run test → PASS
pnpm --filter @repo/core-shared test init-server-node.test
Expected: PASS, 2 tests.
- Step 5: Commit
git add packages/core-shared/src/instrumentation/otel/init-server-node.ts \
packages/core-shared/src/instrumentation/otel/init-server-node.test.ts
git commit -m "feat(core-shared): OTel NodeSDK init helper with Sentry exporter wiring"
Task 1.5: Barrel + ESLint allowlist
Files:
-
Create:
packages/core-shared/src/instrumentation/otel/index.ts -
Modify:
packages/core-eslint/base.js -
Step 1: Create the barrel
Create packages/core-shared/src/instrumentation/otel/index.ts:
export { initOtelServerNode, type InitOtelServerNodeOpts } from "./init-server-node";
export { buildResource, type BuildResourceOpts } from "./resource";
- Step 2: Read current
@sentry/*allowlist incore-eslint/base.js
grep -n "@sentry\|opentelemetry" packages/core-eslint/base.js | head -20
Find the rule block that restricts @sentry/* imports (R40, near line 78–100 area).
- Step 3: Extend allowlist
In packages/core-eslint/base.js, add a new files entry to allowlist @sentry/opentelemetry and OTel SDK packages inside **/instrumentation/otel/**:
// R52 — OTel SDK packages allowed only in core-shared/instrumentation/otel/
{
files: ["**/instrumentation/otel/**/*.{ts,tsx,mjs,cjs,js}"],
rules: {
"no-restricted-imports": "off",
},
},
And in the existing @sentry/* allowlist files array (where it lists instrumentation/sentry/** and similar), add **/instrumentation/otel/sentry-bridge.ts so the bridge file is allowed to import @sentry/opentelemetry.
- Step 4: Verify lint passes
pnpm lint
Expected: 0 errors, only pre-existing warnings.
- Step 5: Commit
git add packages/core-shared/src/instrumentation/otel/index.ts \
packages/core-eslint/base.js
git commit -m "feat(core-shared): OTel barrel + ESLint allowlist for SDK packages"
Task 1.6: Phase 1 verification gate
- Step 1: Run all gates
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green. Lint may have pre-existing warnings about turbo.json env vars; those are not yours to fix.
(No commit; verification gate only.)
Phase 2 — Tracer swap
Goal: OtelTracer becomes the ITracer impl. Sentry receives traces via the OTel pipeline. Feature code untouched.
Files touched:
- Create:
packages/core-shared/src/instrumentation/otel/otel-tracer.ts - Create:
packages/core-shared/src/instrumentation/otel/otel-tracer.test.ts - Rename + modify:
packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts→bind-otel-instrumentation.ts - Modify:
packages/core-shared/src/instrumentation/index.ts - Modify:
apps/web-next/src/server/bind-production.ts - Delete:
packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts+ test - Modify:
packages/core-eslint/base.js
Task 2.1: OtelTracer impl (TDD)
Files:
-
Create:
packages/core-shared/src/instrumentation/otel/otel-tracer.ts -
Create:
packages/core-shared/src/instrumentation/otel/otel-tracer.test.ts -
Step 1: Write the failing test
Create packages/core-shared/src/instrumentation/otel/otel-tracer.test.ts:
import { describe, it, expect, beforeEach } from "vitest";
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { trace } from "@opentelemetry/api";
import { OtelTracer } from "./otel-tracer";
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
trace.setGlobalTracerProvider(provider);
beforeEach(() => exporter.reset());
describe("OtelTracer", () => {
it("creates a span with the given name and attributes", async () => {
const t = new OtelTracer();
await t.startSpan(
{ name: "test-span", op: "use-case", attributes: { foo: "bar", count: 42 } },
async (span) => {
span.setAttribute("inside", "yes");
},
);
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0]!.name).toBe("test-span");
expect(spans[0]!.attributes["span.op"]).toBe("use-case");
expect(spans[0]!.attributes["foo"]).toBe("bar");
expect(spans[0]!.attributes["count"]).toBe(42);
expect(spans[0]!.attributes["inside"]).toBe("yes");
});
it("nests spans correctly using the active context", async () => {
const t = new OtelTracer();
await t.startSpan({ name: "parent" }, async () => {
await t.startSpan({ name: "child" }, async () => {});
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(2);
const child = spans.find((s) => s.name === "child")!;
const parent = spans.find((s) => s.name === "parent")!;
expect(child.parentSpanContext?.spanId).toBe(parent.spanContext().spanId);
});
it("records exceptions and rethrows", async () => {
const t = new OtelTracer();
const error = new Error("boom");
await expect(
t.startSpan({ name: "throws" }, async () => { throw error; }),
).rejects.toThrow("boom");
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0]!.events.some((e) => e.name === "exception")).toBe(true);
expect(spans[0]!.status.code).toBe(2); // SpanStatusCode.ERROR
});
it("setStatus maps ok→OK and error→ERROR", async () => {
const t = new OtelTracer();
await t.startSpan({ name: "ok-span" }, async (span) => { span.setStatus("ok"); });
await t.startSpan({ name: "err-span" }, async (span) => { span.setStatus("error", "boom"); });
const spans = exporter.getFinishedSpans();
expect(spans.find((s) => s.name === "ok-span")!.status.code).toBe(1);
expect(spans.find((s) => s.name === "err-span")!.status.code).toBe(2);
});
it("filters null attribute values", async () => {
const t = new OtelTracer();
await t.startSpan({ name: "null-attrs" }, async (span) => {
span.setAttribute("nullable", null);
span.setAttribute("real", "value");
});
const spans = exporter.getFinishedSpans();
expect(spans[0]!.attributes["nullable"]).toBeUndefined();
expect(spans[0]!.attributes["real"]).toBe("value");
});
});
- Step 2: Run test → FAIL
pnpm --filter @repo/core-shared test otel-tracer.test
Expected: FAIL — module not found.
- Step 3: Implement
Create packages/core-shared/src/instrumentation/otel/otel-tracer.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, string | number | boolean> = {};
if (opts.attributes) {
for (const [k, v] of Object.entries(opts.attributes)) {
if (v !== null) attributes[k] = v;
}
}
if (opts.op) attributes["span.op"] = opts.op;
return this.tracer.startActiveSpan(
opts.name,
{ kind: SpanKind.INTERNAL, attributes },
async (otelSpan) => {
const adapter: ISpan = {
setAttribute(key: string, value: AttributeValue) {
if (value !== null) otelSpan.setAttribute(key, value);
},
setStatus(status: "ok" | "error", message?: string) {
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();
}
},
);
}
}
- Step 4: Run test → PASS
pnpm --filter @repo/core-shared test otel-tracer.test
Expected: PASS, 5 tests.
- Step 5: Commit
git add packages/core-shared/src/instrumentation/otel/otel-tracer.ts \
packages/core-shared/src/instrumentation/otel/otel-tracer.test.ts
git commit -m "feat(core-shared): OtelTracer impl using @opentelemetry/api"
Task 2.2: Rename bind-sentry-instrumentation → bind-otel-instrumentation
Files:
-
Rename + modify:
packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts→bind-otel-instrumentation.ts -
Rename + modify:
packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.test.ts→bind-otel-instrumentation.test.ts -
Modify:
packages/core-shared/src/instrumentation/index.ts -
Step 1: Read current
bind-sentry-instrumentation.ts
cat packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts
Note the current shape: it constructs SentryTracer and SentryLogger, binds them to symbols.
- Step 2: Rename files via git
git mv packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts \
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts
git mv packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.test.ts \
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.test.ts
- Step 3: Rewrite the impl
Replace the contents of packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts with:
import "reflect-metadata";
import type { Container } from "inversify";
import { initOtelServerNode } from "../otel/init-server-node";
import { OtelTracer } from "../otel/otel-tracer";
import { SentryLogger } from "../sentry/sentry-logger"; // Replaced in Phase 3 with OtelLogger
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
import type { ITracer } from "../tracer.interface";
import type { ILogger } from "../logger.interface";
export type BindOtelOpts = {
/** Sentry DSN. Required for the Sentry exporter; empty means no Sentry sink. */
dsn: string;
/** Logical app/service name (e.g. "web-next", "cms", "web-tanstack"). */
app: string;
/** Deployment environment (e.g. "production", "staging", "development"). */
environment?: string;
};
/**
* Binds OTel-based instrumentation. Initializes the OTel NodeSDK with the
* Sentry exporter wired (when DSN is set), then binds OtelTracer + SentryLogger
* (logger swap lands in Phase 3).
*/
export function bindOtelInstrumentation(
container: Container,
opts: BindOtelOpts,
): { tracer: ITracer; logger: ILogger } {
const sdk = initOtelServerNode({
dsn: opts.dsn,
serviceName: opts.app,
environment: opts.environment ?? process.env.NODE_ENV ?? "development",
});
// Best-effort shutdown on process exit; not all environments will call this.
process.once("beforeExit", () => { void sdk.shutdown(); });
const tracer = new OtelTracer();
const logger = new SentryLogger(); // Phase 3 replaces this with OtelLogger
if (container.isBound(INSTRUMENTATION_SYMBOLS.ITracer)) {
container.unbind(INSTRUMENTATION_SYMBOLS.ITracer);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.ILogger)) {
container.unbind(INSTRUMENTATION_SYMBOLS.ILogger);
}
container.bind<ITracer>(INSTRUMENTATION_SYMBOLS.ITracer).toConstantValue(tracer);
container.bind<ILogger>(INSTRUMENTATION_SYMBOLS.ILogger).toConstantValue(logger);
return { tracer, logger };
}
- Step 4: Update test file
In packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.test.ts:
- Update imports:
bindSentryInstrumentation→bindOtelInstrumentation. - Update test names from "binds Sentry instrumentation" to "binds OTel instrumentation".
- Update assertions: tracer should now be
OtelTracerinstance, notSentryTracer.
If the original test file constructs SentryTracer directly for assertions, change to OtelTracer. Keep the structural assertions (bound to right symbol, returns the right shape) unchanged.
- Step 5: Update barrel exports
In packages/core-shared/src/instrumentation/index.ts, replace:
export {
bindSentryInstrumentation,
type BindSentryOpts,
} from "./di/bind-sentry-instrumentation";
with:
export {
bindOtelInstrumentation,
type BindOtelOpts,
} from "./di/bind-otel-instrumentation";
// Deprecated alias for one release cycle. Remove in a future cleanup PR.
export { bindOtelInstrumentation as bindSentryInstrumentation } from "./di/bind-otel-instrumentation";
export type { BindOtelOpts as BindSentryOpts } from "./di/bind-otel-instrumentation";
- Step 6: Run tests
pnpm --filter @repo/core-shared test bind-otel-instrumentation
Expected: PASS.
- Step 7: Commit
git add packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts \
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.test.ts \
packages/core-shared/src/instrumentation/index.ts
git commit -m "refactor(core-shared): rename bindSentryInstrumentation → bindOtelInstrumentation"
Task 2.3: Update app aggregators
Files:
-
Modify:
apps/web-next/src/server/bind-production.ts -
Modify: (if applicable)
apps/cms/...andapps/web-tanstack/... -
Step 1: Find call sites
grep -rn "bindSentryInstrumentation" apps/ 2>/dev/null
- Step 2: Update each call site
For each match, change bindSentryInstrumentation(...) to bindOtelInstrumentation(...). Update the imported name too if it's imported under the old name.
The deprecation alias from Task 2.2 means the old name still resolves; this step is for explicit cleanup.
- Step 3: Run gates
pnpm lint && pnpm typecheck
Expected: 0 errors.
- Step 4: Commit
git add apps/web-next/src/server/bind-production.ts
# Plus any other apps that needed updates
git commit -m "refactor(apps): call sites use bindOtelInstrumentation by name"
Task 2.4: Delete SentryTracer + ESLint allowlist narrowing
Files:
-
Delete:
packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts -
Delete:
packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts -
Modify:
packages/core-eslint/base.js -
Step 1: Delete the files
rm packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts
rm packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts
- Step 2: Update ESLint allowlist
In packages/core-eslint/base.js, find the @sentry/* allowlist rule block (the one that lists **/instrumentation/sentry/** etc.). Remove sentry-tracer.{ts,js} if it's individually listed (the sentry/** directory pattern probably covers it; verify nothing references the deleted file).
- Step 3: Run gates
pnpm lint && pnpm typecheck && pnpm test
Expected: all green. The SentryTracer deletion should not break anything because bindOtelInstrumentation no longer references it.
- Step 4: Commit
git add packages/core-eslint/base.js \
-- packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts \
-- packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts
git commit -m "refactor(core-shared): delete SentryTracer (replaced by OtelTracer)"
Task 2.5: Phase 2 verification gate
- Step 1: Run all gates
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green.
(No commit; verification gate only.)
Phase 3 — Logger swap
Goal: OtelLogger becomes the ILogger impl, emitting via OTel Logs API. Sentry receives errors via the OTel log record exporter.
Files touched:
- Create:
packages/core-shared/src/instrumentation/otel/otel-logger.ts - Create:
packages/core-shared/src/instrumentation/otel/otel-logger.test.ts - Modify:
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts - Modify:
packages/core-shared/src/instrumentation/otel/init-server-node.ts - Modify:
packages/core-shared/package.json - Delete:
packages/core-shared/src/instrumentation/sentry/sentry-logger.ts+ test - Modify:
packages/core-eslint/base.js
Task 3.1: Add OTel Logs API dependency
Files:
-
Modify:
packages/core-shared/package.json -
Step 1: Add deps
In packages/core-shared/package.json dependencies, add (alphabetically):
"@opentelemetry/api-logs": "^0.55.0",
"@opentelemetry/sdk-logs": "^0.55.0"
Match the version family of the other 0.x OTel SDK packages already added in Phase 1.
- Step 2: Install
pnpm install
- Step 3: Commit
git add packages/core-shared/package.json pnpm-lock.yaml
git commit -m "feat(core-shared): add @opentelemetry/api-logs + sdk-logs deps"
Task 3.2: OtelLogger impl (TDD)
Files:
-
Create:
packages/core-shared/src/instrumentation/otel/otel-logger.ts -
Create:
packages/core-shared/src/instrumentation/otel/otel-logger.test.ts -
Step 1: Write the failing test
Create packages/core-shared/src/instrumentation/otel/otel-logger.test.ts:
import { describe, it, expect, beforeEach } from "vitest";
import { LoggerProvider, InMemoryLogRecordExporter, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { logs } from "@opentelemetry/api-logs";
import { trace } from "@opentelemetry/api";
import { OtelLogger } from "./otel-logger";
import { OtelTracer } from "./otel-tracer";
const logExporter = new InMemoryLogRecordExporter();
const logProvider = new LoggerProvider({ processors: [new SimpleLogRecordProcessor(logExporter)] });
logs.setGlobalLoggerProvider(logProvider);
const spanExporter = new InMemorySpanExporter();
const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(spanExporter)] });
trace.setGlobalTracerProvider(tracerProvider);
beforeEach(() => {
logExporter.reset();
spanExporter.reset();
});
describe("OtelLogger", () => {
it("captureException emits an ERROR-severity log record with exception attributes", () => {
const l = new OtelLogger();
const err = new Error("boom");
l.captureException(err, { tags: { feature: "auth" } });
const records = logExporter.getFinishedLogRecords();
expect(records).toHaveLength(1);
expect(records[0]!.severityText).toBe("ERROR");
expect(records[0]!.attributes["exception.type"]).toBe("Error");
expect(records[0]!.attributes["exception.message"]).toBe("boom");
expect(records[0]!.attributes["tag.feature"]).toBe("auth");
});
it("captureException honors the double-report guard", () => {
const l = new OtelLogger();
const err = new Error("once");
l.captureException(err);
l.captureException(err);
expect(logExporter.getFinishedLogRecords()).toHaveLength(1);
});
it("captureMessage maps levels to severity correctly", () => {
const l = new OtelLogger();
l.captureMessage("info-msg", "info");
l.captureMessage("warn-msg", "warning");
l.captureMessage("err-msg", "error");
const records = logExporter.getFinishedLogRecords();
expect(records.find((r) => r.body === "info-msg")!.severityText).toBe("INFO");
expect(records.find((r) => r.body === "warn-msg")!.severityText).toBe("WARN");
expect(records.find((r) => r.body === "err-msg")!.severityText).toBe("ERROR");
});
it("addBreadcrumb attaches a span event to the active span", async () => {
const l = new OtelLogger();
const t = new OtelTracer();
await t.startSpan({ name: "test" }, async () => {
l.addBreadcrumb({ category: "auth", message: "user signed in", level: "info" });
});
const spans = spanExporter.getFinishedSpans();
expect(spans[0]!.events).toHaveLength(1);
expect(spans[0]!.events[0]!.name).toBe("user signed in");
expect(spans[0]!.events[0]!.attributes!["breadcrumb.category"]).toBe("auth");
});
it("addBreadcrumb is a no-op when there is no active span", () => {
const l = new OtelLogger();
l.addBreadcrumb({ category: "auth", message: "no span context" });
// No throw; nothing to assert. The lack of an active span is the test.
expect(spanExporter.getFinishedSpans()).toHaveLength(0);
});
it("setUser sets user.id on the active span", async () => {
const l = new OtelLogger();
const t = new OtelTracer();
await t.startSpan({ name: "test" }, async () => {
l.setUser({ id: "user_123" });
});
const spans = spanExporter.getFinishedSpans();
expect(spans[0]!.attributes["user.id"]).toBe("user_123");
});
});
- Step 2: Run test → FAIL
pnpm --filter @repo/core-shared test otel-logger.test
Expected: FAIL — module not found.
- Step 3: Implement
Create packages/core-shared/src/instrumentation/otel/otel-logger.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, severityText } =
level === "error" ? { severityNumber: SeverityNumber.ERROR, severityText: "ERROR" } :
level === "warning" ? { severityNumber: SeverityNumber.WARN, severityText: "WARN" } :
{ severityNumber: SeverityNumber.INFO, severityText: "INFO" };
this.logger.emit({
severityNumber,
severityText,
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>): Record<string, string> {
if (!tags) return {};
return Object.fromEntries(Object.entries(tags).map(([k, v]) => [`tag.${k}`, v]));
}
function flattenExtras(extras?: Record<string, unknown>): Record<string, string> {
if (!extras) return {};
return Object.fromEntries(
Object.entries(extras).map(([k, v]) => [
`extra.${k}`,
typeof v === "string" ? v : JSON.stringify(v),
]),
);
}
- Step 4: Run test → PASS
pnpm --filter @repo/core-shared test otel-logger.test
Expected: PASS, 6 tests.
- Step 5: Commit
git add packages/core-shared/src/instrumentation/otel/otel-logger.ts \
packages/core-shared/src/instrumentation/otel/otel-logger.test.ts
git commit -m "feat(core-shared): OtelLogger impl using @opentelemetry/api-logs"
Task 3.3: Wire LogRecordProcessor + swap binding
Files:
-
Modify:
packages/core-shared/src/instrumentation/otel/init-server-node.ts -
Modify:
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts -
Step 1: Update init helper to wire the log record processor
In packages/core-shared/src/instrumentation/otel/init-server-node.ts, add the log record processor:
import { NodeSDK } from "@opentelemetry/sdk-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { buildResource } from "./resource";
import { createSentryOtelBridge } from "./sentry-bridge";
// ... existing InitOtelServerNodeOpts type ...
export function initOtelServerNode(opts: InitOtelServerNodeOpts): NodeSDK {
const resource = buildResource({
serviceName: opts.serviceName,
serviceVersion: opts.serviceVersion,
environment: opts.environment,
namespace: opts.namespace,
});
const bridge = createSentryOtelBridge({ dsn: opts.dsn });
const spanProcessors = bridge.spanProcessor
? [new BatchSpanProcessor(bridge.spanProcessor as never)]
: [];
const logRecordProcessors = bridge.logRecordProcessor
? [new BatchLogRecordProcessor(bridge.logRecordProcessor as never)]
: [];
const sdk = new NodeSDK({
resource,
spanProcessors,
logRecordProcessors,
// metricReader filled in Phase 4
});
sdk.start();
return sdk;
}
- Step 2: Swap SentryLogger → OtelLogger in bind-otel-instrumentation
In packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts, replace:
import { SentryLogger } from "../sentry/sentry-logger"; // Replaced in Phase 3 with OtelLogger
// ...
const logger = new SentryLogger(); // Phase 3 replaces this with OtelLogger
with:
import { OtelLogger } from "../otel/otel-logger";
// ...
const logger = new OtelLogger();
- Step 3: Run tests
pnpm --filter @repo/core-shared test
Expected: all PASS. The init-server-node.test may need a small update if it tested specifics that have changed; verify and adjust if needed.
- Step 4: Commit
git add packages/core-shared/src/instrumentation/otel/init-server-node.ts \
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts
git commit -m "feat(core-shared): wire OtelLogger + LogRecordProcessor in OTel pipeline"
Task 3.4: Delete SentryLogger + ESLint narrowing
Files:
-
Delete:
packages/core-shared/src/instrumentation/sentry/sentry-logger.ts -
Delete:
packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts -
Modify:
packages/core-eslint/base.js -
Step 1: Delete files
rm packages/core-shared/src/instrumentation/sentry/sentry-logger.ts
rm packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts
- Step 2: Update ESLint allowlist
In packages/core-eslint/base.js, if sentry-logger.{ts,js} is individually listed in the @sentry/* allowlist, remove it. The sentry/** directory pattern continues to cover the remaining scrub.ts + pii-fields.ts (deleted in Phase 5).
- Step 3: Run gates
pnpm lint && pnpm typecheck && pnpm test
Expected: all green.
- Step 4: Commit
git add packages/core-eslint/base.js \
-- packages/core-shared/src/instrumentation/sentry/sentry-logger.ts \
-- packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts
git commit -m "refactor(core-shared): delete SentryLogger (replaced by OtelLogger)"
Task 3.5: Phase 3 verification gate
- Step 1: Run all gates
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green. The bind-production.test.ts in apps/web-next should pass unchanged — the binder swap doesn't change what's passed to feature binders.
(No commit; verification gate only.)
Phase 4 — Metrics introduction
Goal: New IMetrics interface alongside ITracer/ILogger. Three impls (Noop, Otel, Recording). Added to BindContext as optional field.
Files touched:
- Create:
packages/core-shared/src/instrumentation/metrics.interface.ts - Create:
packages/core-shared/src/instrumentation/noop-metrics.ts - Create:
packages/core-shared/src/instrumentation/noop-metrics.test.ts - Create:
packages/core-shared/src/instrumentation/otel/otel-metrics.ts - Create:
packages/core-shared/src/instrumentation/otel/otel-metrics.test.ts - Create:
packages/core-testing/src/instrumentation/recording-metrics.ts - Create:
packages/core-testing/src/instrumentation/recording-metrics.test.ts - Modify:
packages/core-shared/src/instrumentation/index.ts - Modify:
packages/core-shared/src/instrumentation/symbols.ts - Modify:
packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts - Modify:
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts - Modify:
packages/core-shared/src/instrumentation/otel/init-server-node.ts - Modify:
packages/core-shared/src/di/bind-protocols.ts - Modify:
packages/core-shared/src/di/bind-context.ts - Modify:
packages/core-shared/package.json - Modify:
packages/core-testing/src/instrumentation/index.ts
Task 4.1: Add sdk-metrics dependency
- Step 1: Add deps
In packages/core-shared/package.json:
"@opentelemetry/sdk-metrics": "^1.27.0"
(Metrics API lives in @opentelemetry/api already added in Phase 1; no separate @opentelemetry/api-metrics package needed.)
- Step 2: Install
pnpm install
- Step 3: Commit
git add packages/core-shared/package.json pnpm-lock.yaml
git commit -m "feat(core-shared): add @opentelemetry/sdk-metrics dep"
Task 4.2: IMetrics interface + NoopMetrics (TDD)
Files:
-
Create:
packages/core-shared/src/instrumentation/metrics.interface.ts -
Create:
packages/core-shared/src/instrumentation/noop-metrics.ts -
Create:
packages/core-shared/src/instrumentation/noop-metrics.test.ts -
Step 1: Write the failing test for NoopMetrics
Create packages/core-shared/src/instrumentation/noop-metrics.test.ts:
import { describe, it, expect } from "vitest";
import { NoopMetrics } from "./noop-metrics";
describe("NoopMetrics", () => {
it("counter, histogram, gauge are no-ops that do not throw", () => {
const m = new NoopMetrics();
expect(() => m.counter("requests", 1, { route: "/" })).not.toThrow();
expect(() => m.counter("requests")).not.toThrow();
expect(() => m.histogram("latency_ms", 42, { route: "/" })).not.toThrow();
expect(() => m.gauge("queue_depth", 17)).not.toThrow();
});
});
- Step 2: Run → FAIL
pnpm --filter @repo/core-shared test noop-metrics.test
- Step 3: Create the interface
Create packages/core-shared/src/instrumentation/metrics.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. Uses UpDownCounter under the hood — true "set" gauge
* semantics require an ObservableGauge with a periodic callback, which is a
* future v2 interface bump.
*/
gauge(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
}
- Step 4: Create NoopMetrics
Create packages/core-shared/src/instrumentation/noop-metrics.ts:
import type { IMetrics, MetricAttributeValue } from "./metrics.interface";
export class NoopMetrics implements IMetrics {
counter(_name: string, _value?: number, _attributes?: Record<string, MetricAttributeValue>): void {}
histogram(_name: string, _value: number, _attributes?: Record<string, MetricAttributeValue>): void {}
gauge(_name: string, _value: number, _attributes?: Record<string, MetricAttributeValue>): void {}
}
- Step 5: Run → PASS
pnpm --filter @repo/core-shared test noop-metrics.test
- Step 6: Commit
git add packages/core-shared/src/instrumentation/metrics.interface.ts \
packages/core-shared/src/instrumentation/noop-metrics.ts \
packages/core-shared/src/instrumentation/noop-metrics.test.ts
git commit -m "feat(core-shared): IMetrics interface + NoopMetrics impl"
Task 4.3: OtelMetrics (TDD)
Files:
-
Create:
packages/core-shared/src/instrumentation/otel/otel-metrics.ts -
Create:
packages/core-shared/src/instrumentation/otel/otel-metrics.test.ts -
Step 1: Write the failing test
Create packages/core-shared/src/instrumentation/otel/otel-metrics.test.ts:
import { describe, it, expect, beforeEach } from "vitest";
import { MeterProvider, InMemoryMetricExporter, PeriodicExportingMetricReader, AggregationTemporality } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { OtelMetrics } from "./otel-metrics";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 50 });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
beforeEach(() => exporter.reset());
async function flush(): Promise<void> {
await provider.forceFlush();
}
describe("OtelMetrics", () => {
it("counter increments by the given value", async () => {
const m = new OtelMetrics();
m.counter("test_counter", 5, { route: "/" });
m.counter("test_counter", 3, { route: "/" });
await flush();
const exported = exporter.getMetrics();
const counter = exported[0]!.scopeMetrics[0]!.metrics.find((mm) => mm.descriptor.name === "test_counter");
expect(counter).toBeDefined();
expect(counter!.dataPoints[0]!.value).toBe(8);
});
it("histogram records values", async () => {
const m = new OtelMetrics();
m.histogram("test_latency", 100);
m.histogram("test_latency", 200);
await flush();
const exported = exporter.getMetrics();
const histogram = exported[0]!.scopeMetrics[0]!.metrics.find((mm) => mm.descriptor.name === "test_latency");
expect(histogram).toBeDefined();
});
it("gauge accumulates as UpDownCounter (synchronous emit)", async () => {
const m = new OtelMetrics();
m.gauge("test_queue_depth", 17);
await flush();
const exported = exporter.getMetrics();
const gauge = exported[0]!.scopeMetrics[0]!.metrics.find((mm) => mm.descriptor.name === "test_queue_depth");
expect(gauge).toBeDefined();
});
});
- Step 2: Run → FAIL
pnpm --filter @repo/core-shared test otel-metrics.test
- Step 3: Implement
Create packages/core-shared/src/instrumentation/otel/otel-metrics.ts:
import { metrics, type Counter, type Histogram, type UpDownCounter } from "@opentelemetry/api";
import type { IMetrics, MetricAttributeValue } from "../metrics.interface";
export class OtelMetrics implements IMetrics {
private readonly meter = metrics.getMeter("@repo/core-shared", "1.0.0");
private readonly counters = new Map<string, Counter>();
private readonly histograms = new Map<string, Histogram>();
private readonly gauges = new Map<string, UpDownCounter>();
counter(name: string, value: number = 1, attributes?: Record<string, MetricAttributeValue>): void {
let c = this.counters.get(name);
if (!c) {
c = this.meter.createCounter(name);
this.counters.set(name, c);
}
c.add(value, attributes);
}
histogram(name: string, value: number, attributes?: Record<string, MetricAttributeValue>): void {
let h = this.histograms.get(name);
if (!h) {
h = this.meter.createHistogram(name);
this.histograms.set(name, h);
}
h.record(value, attributes);
}
gauge(name: string, value: number, attributes?: Record<string, MetricAttributeValue>): void {
let g = this.gauges.get(name);
if (!g) {
g = this.meter.createUpDownCounter(name);
this.gauges.set(name, g);
}
g.add(value, attributes);
}
}
- Step 4: Run → PASS
pnpm --filter @repo/core-shared test otel-metrics.test
- Step 5: Commit
git add packages/core-shared/src/instrumentation/otel/otel-metrics.ts \
packages/core-shared/src/instrumentation/otel/otel-metrics.test.ts
git commit -m "feat(core-shared): OtelMetrics impl using @opentelemetry/api metrics"
Task 4.4: RecordingMetrics in core-testing
Files:
-
Create:
packages/core-testing/src/instrumentation/recording-metrics.ts -
Create:
packages/core-testing/src/instrumentation/recording-metrics.test.ts -
Modify:
packages/core-testing/src/instrumentation/index.ts -
Step 1: Write the failing test
Create packages/core-testing/src/instrumentation/recording-metrics.test.ts:
import { describe, it, expect } from "vitest";
import { RecordingMetrics } from "./recording-metrics";
describe("RecordingMetrics", () => {
it("captures counter, histogram, and gauge calls in `recorded`", () => {
const m = new RecordingMetrics();
m.counter("a", 1, { route: "/" });
m.histogram("b", 42);
m.gauge("c", 17, { service: "x" });
expect(m.recorded).toEqual([
{ kind: "counter", name: "a", value: 1, attributes: { route: "/" } },
{ kind: "histogram", name: "b", value: 42, attributes: undefined },
{ kind: "gauge", name: "c", value: 17, attributes: { service: "x" } },
]);
});
it("counter defaults to value 1 when omitted", () => {
const m = new RecordingMetrics();
m.counter("a");
expect(m.recorded[0]).toEqual({ kind: "counter", name: "a", value: 1, attributes: undefined });
});
it("reset() clears recorded entries", () => {
const m = new RecordingMetrics();
m.counter("a");
m.reset();
expect(m.recorded).toEqual([]);
});
});
- Step 2: Run → FAIL
pnpm --filter @repo/core-testing test recording-metrics.test
- Step 3: Implement
Create packages/core-testing/src/instrumentation/recording-metrics.ts:
import type { IMetrics, MetricAttributeValue } from "@repo/core-shared/instrumentation";
export type RecordedMetric =
| { kind: "counter"; name: string; value: number; attributes?: Record<string, MetricAttributeValue> }
| { kind: "histogram"; name: string; value: number; attributes?: Record<string, MetricAttributeValue> }
| { kind: "gauge"; name: string; value: number; attributes?: Record<string, MetricAttributeValue> };
export class RecordingMetrics implements IMetrics {
public recorded: RecordedMetric[] = [];
counter(name: string, value: number = 1, attributes?: Record<string, MetricAttributeValue>): void {
this.recorded.push({ kind: "counter", name, value, attributes });
}
histogram(name: string, value: number, attributes?: Record<string, MetricAttributeValue>): void {
this.recorded.push({ kind: "histogram", name, value, attributes });
}
gauge(name: string, value: number, attributes?: Record<string, MetricAttributeValue>): void {
this.recorded.push({ kind: "gauge", name, value, attributes });
}
reset(): void {
this.recorded = [];
}
}
- Step 4: Update core-testing barrel
In packages/core-testing/src/instrumentation/index.ts, append:
export { RecordingMetrics, type RecordedMetric } from "./recording-metrics";
- Step 5: Run → PASS
pnpm --filter @repo/core-testing test recording-metrics.test
- Step 6: Commit
git add packages/core-testing/src/instrumentation/recording-metrics.ts \
packages/core-testing/src/instrumentation/recording-metrics.test.ts \
packages/core-testing/src/instrumentation/index.ts
git commit -m "feat(core-testing): RecordingMetrics test double"
Task 4.5: Wire IMetrics into symbols + DI bindings + barrel
Files:
-
Modify:
packages/core-shared/src/instrumentation/symbols.ts -
Modify:
packages/core-shared/src/instrumentation/index.ts -
Modify:
packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts -
Modify:
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts -
Modify:
packages/core-shared/src/instrumentation/otel/init-server-node.ts -
Step 1: Add the IMetrics symbol
In packages/core-shared/src/instrumentation/symbols.ts:
export const INSTRUMENTATION_SYMBOLS = {
ITracer: Symbol.for("core-shared:ITracer"),
ILogger: Symbol.for("core-shared:ILogger"),
IMetrics: Symbol.for("core-shared:IMetrics"), // <-- new
} as const;
- Step 2: Export from barrel
In packages/core-shared/src/instrumentation/index.ts, add:
export type { IMetrics, MetricAttributeValue } from "./metrics.interface";
export { NoopMetrics } from "./noop-metrics";
- Step 3: Wire NoopMetrics in bind-noop
In packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts:
import { NoopTracer } from "../noop-tracer";
import { NoopLogger } from "../noop-logger";
import { NoopMetrics } from "../noop-metrics"; // <-- new
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
// ... existing imports
export function bindNoopInstrumentation(container: Container): { tracer: ITracer; logger: ILogger; metrics: IMetrics } {
// ... existing unbind/bind for tracer + logger
const metrics = new NoopMetrics();
if (container.isBound(INSTRUMENTATION_SYMBOLS.IMetrics)) {
container.unbind(INSTRUMENTATION_SYMBOLS.IMetrics);
}
container.bind<IMetrics>(INSTRUMENTATION_SYMBOLS.IMetrics).toConstantValue(metrics);
return { tracer, logger, metrics };
}
- Step 4: Wire OtelMetrics in bind-otel + MeterProvider in init
In packages/core-shared/src/instrumentation/otel/init-server-node.ts, add the MeterProvider integration. The Sentry bridge in Phase 1 didn't include a metric exporter — add one now via the same bridge pattern. For now, the bridge returns null for metrics (Sentry metrics are experimental); the OTel SDK boots with no metric reader unless one is explicitly configured.
Specifically, register a PeriodicExportingMetricReader only if bridge.metricExporter is set. Update sentry-bridge.ts to also return a metricExporter: null (placeholder):
In packages/core-shared/src/instrumentation/otel/sentry-bridge.ts, update the return type:
export type SentryOtelBridge = {
spanProcessor: SpanProcessor | null;
logRecordProcessor: LogRecordProcessor | null;
metricExporter: null; // Sentry metrics not yet wired; placeholder for future
};
And in the impl, return metricExporter: null in both branches.
In init-server-node.ts, the metric reader stays absent (just metrics: false or omit). Update the test if needed.
In packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts:
import { OtelMetrics } from "../otel/otel-metrics";
// ...
const metrics = new OtelMetrics();
if (container.isBound(INSTRUMENTATION_SYMBOLS.IMetrics)) {
container.unbind(INSTRUMENTATION_SYMBOLS.IMetrics);
}
container.bind<IMetrics>(INSTRUMENTATION_SYMBOLS.IMetrics).toConstantValue(metrics);
return { tracer, logger, metrics };
- Step 5: Run tests
pnpm --filter @repo/core-shared test
Expected: all pass.
- Step 6: Commit
git add packages/core-shared/src/instrumentation/symbols.ts \
packages/core-shared/src/instrumentation/index.ts \
packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts \
packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts \
packages/core-shared/src/instrumentation/otel/init-server-node.ts \
packages/core-shared/src/instrumentation/otel/sentry-bridge.ts
git commit -m "feat(core-shared): wire IMetrics into DI bindings"
Task 4.6: Add MetricsProtocol + extend BindContext
Files:
-
Modify:
packages/core-shared/src/di/bind-protocols.ts -
Modify:
packages/core-shared/src/di/bind-context.ts -
Modify:
packages/core-shared/src/instrumentation/metrics.interface.ts -
Step 1: Add MetricsProtocol to bind-protocols
In packages/core-shared/src/di/bind-protocols.ts, append:
export type MetricsProtocol = {
counter(
name: string,
value?: number,
attributes?: Record<string, string | number | boolean>,
): void;
histogram(
name: string,
value: number,
attributes?: Record<string, string | number | boolean>,
): void;
gauge(
name: string,
value: number,
attributes?: Record<string, string | number | boolean>,
): void;
};
- Step 2: Make IMetrics extend MetricsProtocol
In packages/core-shared/src/instrumentation/metrics.interface.ts, update the interface declaration:
import type { MetricsProtocol } from "../di/bind-protocols";
export type MetricAttributeValue = string | number | boolean;
export interface IMetrics extends MetricsProtocol {}
(MetricsProtocol's shape covers the three methods exactly; IMetrics doesn't add anything beyond the protocol for now.)
- Step 3: Extend BindContext
In packages/core-shared/src/di/bind-context.ts, extend the generic and add the metrics field:
import type {
EventBusProtocol,
RealtimeBroadcasterProtocol,
RealtimeRegistryProtocol,
MetricsProtocol,
} from "./bind-protocols";
// ... existing BindContextBase ...
export type BindContext<
Bus extends EventBusProtocol = EventBusProtocol,
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
Metrics extends MetricsProtocol = MetricsProtocol,
> = BindContextBase & {
bus?: Bus;
queue?: IJobQueue;
realtime?: Realtime;
realtimeRegistry?: RealtimeReg;
metrics?: Metrics;
};
export type BindProductionContext<
Bus extends EventBusProtocol = EventBusProtocol,
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
Metrics extends MetricsProtocol = MetricsProtocol,
> = BindContext<Bus, Realtime, RealtimeReg, Metrics> & {
config: SanitizedConfig;
};
- Step 4: Run typecheck
pnpm --filter @repo/core-shared typecheck
Expected: clean.
- Step 5: Run web-next + feature typechecks
pnpm typecheck
Expected: all green. The optional metrics? field is backward-compatible — existing call sites don't have to provide it.
- Step 6: Commit
git add packages/core-shared/src/di/bind-protocols.ts \
packages/core-shared/src/di/bind-context.ts \
packages/core-shared/src/instrumentation/metrics.interface.ts
git commit -m "feat(core-shared): MetricsProtocol + BindContext.metrics? field"
Task 4.7: Phase 4 verification gate
- Step 1: Run all gates
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green. No feature call sites have been added for metrics — that's per-feature, opportunistic.
(No commit; verification gate only.)
Phase 5 — Auto-instrumentations + PII scrub + cleanup
Goal: Enable OTel auto-instrumentations, move PII scrubbing to OTel processors, delete remaining Sentry-direct files, publish ADR-017.
Files touched:
- Modify:
packages/core-shared/package.json - Modify:
packages/core-shared/src/instrumentation/otel/init-server-node.ts - Create:
packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts - Create:
packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts - Move:
packages/core-shared/src/instrumentation/sentry/pii-fields.ts→packages/core-shared/src/instrumentation/otel/pii-fields.ts - Delete:
packages/core-shared/src/instrumentation/sentry/scrub.ts+ test - Modify:
packages/core-shared/src/instrumentation/index.ts - Modify:
packages/core-shared/package.json(drop sentry subpaths) - Modify:
packages/core-eslint/base.js(final allowlist) - Rename:
packages/core-testing/src/setup/no-sentry.ts→no-instrumentation.ts - Modify:
packages/core-testing/package.json(subpath export) - Create:
docs/decisions/adr-017-opentelemetry-migration.md - Modify:
docs/decisions/adr-014-instrumentation-sentry.md - Modify:
CLAUDE.md,AGENTS.md,docs/architecture/dependency-flow.md,docs/architecture/vertical-feature-spec.md,docs/architecture/di-explainer.html,docs/architecture/data-flow-explainer.html
Task 5.1: Add auto-instrumentation dependencies
- Step 1: Add deps
In packages/core-shared/package.json:
"@opentelemetry/instrumentation": "^0.55.0",
"@opentelemetry/instrumentation-http": "^0.55.0",
"@opentelemetry/instrumentation-undici": "^0.10.0",
"@opentelemetry/instrumentation-pg": "^0.50.0"
(Versions should align with the OTel JS family already pinned. Use ^0.55.0 style ranges that match the sdk packages from Phase 1.)
- Step 2: Install
pnpm install
- Step 3: Commit
git add packages/core-shared/package.json pnpm-lock.yaml
git commit -m "feat(core-shared): add OTel auto-instrumentation deps (http + undici + pg)"
Task 5.2: Register auto-instrumentations
Files:
-
Modify:
packages/core-shared/src/instrumentation/otel/init-server-node.ts -
Step 1: Add the registerInstrumentations call
Add to init-server-node.ts:
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
// ... existing initOtelServerNode function:
export function initOtelServerNode(opts: InitOtelServerNodeOpts): NodeSDK {
// ... existing resource + bridge + sdk construction ...
sdk.start();
registerInstrumentations({
instrumentations: [
new HttpInstrumentation({
requestHook: (span, request) => {
const url = (request as { url?: string }).url ?? "";
span.setAttribute("http.url.path", url.split("?")[0] ?? "");
},
ignoreIncomingRequestHook: (req) => {
const url = (req as { url?: string }).url ?? "";
return url === "/_health" || url === "/_otel-export";
},
}),
new UndiciInstrumentation(),
new PgInstrumentation({ enhancedDatabaseReporting: false }),
],
});
return sdk;
}
- Step 2: Run tests
pnpm --filter @repo/core-shared test init-server-node.test
Expected: PASS. Note: the test uses an empty DSN so the SDK boots without actual exporter wiring; the auto-instrumentations are registered but don't fire in unit-test context.
- Step 3: Commit
git add packages/core-shared/src/instrumentation/otel/init-server-node.ts
git commit -m "feat(core-shared): enable OTel auto-instrumentations (http + undici + pg)"
Task 5.3: Move pii-fields + create PII scrub processors (TDD)
Files:
-
Move:
packages/core-shared/src/instrumentation/sentry/pii-fields.ts→packages/core-shared/src/instrumentation/otel/pii-fields.ts -
Create:
packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts -
Create:
packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts -
Step 1: Move pii-fields
git mv packages/core-shared/src/instrumentation/sentry/pii-fields.ts \
packages/core-shared/src/instrumentation/otel/pii-fields.ts
Update imports in any file that referenced the old path. Grep for sentry/pii-fields and update them:
grep -rn "sentry/pii-fields" packages/core-shared/ 2>/dev/null
For each match, change the import path to ../otel/pii-fields or similar.
- Step 2: Write the failing test
Create packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts:
import { describe, it, expect, beforeEach } from "vitest";
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { LoggerProvider, InMemoryLogRecordExporter, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { SeverityNumber, logs } from "@opentelemetry/api-logs";
import { trace } from "@opentelemetry/api";
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
const spanExporter = new InMemorySpanExporter();
const tracerProvider = new BasicTracerProvider({
spanProcessors: [new PiiScrubSpanProcessor(), new SimpleSpanProcessor(spanExporter)],
});
trace.setGlobalTracerProvider(tracerProvider);
const logExporter = new InMemoryLogRecordExporter();
const logProvider = new LoggerProvider({
processors: [new PiiScrubLogRecordProcessor(), new SimpleLogRecordProcessor(logExporter)],
});
logs.setGlobalLoggerProvider(logProvider);
beforeEach(() => {
spanExporter.reset();
logExporter.reset();
});
describe("PiiScrubSpanProcessor", () => {
it("redacts attributes whose names contain PII substrings", () => {
const tracer = trace.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: {
"user.email": "alice@example.com",
"user.id": "u_123",
"auth.token": "secret-token",
"request.path": "/api/users",
},
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["user.email"]).toBe("[redacted]");
expect(exported[0]!.attributes["auth.token"]).toBe("[redacted]");
expect(exported[0]!.attributes["user.id"]).toBe("u_123"); // id is fine per R36
expect(exported[0]!.attributes["request.path"]).toBe("/api/users");
});
});
describe("PiiScrubLogRecordProcessor", () => {
it("redacts log record attributes whose names contain PII substrings", () => {
const logger = logs.getLogger("test");
logger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: "ERROR",
body: "test",
attributes: {
"user.email": "alice@example.com",
"exception.message": "boom",
},
});
const records = logExporter.getFinishedLogRecords();
expect(records[0]!.attributes!["user.email"]).toBe("[redacted]");
expect(records[0]!.attributes!["exception.message"]).toBe("boom");
});
it("redacts log body when it contains PII substrings", () => {
const logger = logs.getLogger("test");
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: "user signed in with email alice@example.com",
});
const records = logExporter.getFinishedLogRecords();
expect(records[0]!.body).toBe("[redacted]");
});
});
- Step 3: Run → FAIL
pnpm --filter @repo/core-shared test pii-scrub-processor.test
- Step 4: Implement
Create packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts:
import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base";
import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs";
import { PII_SUBSTRINGS } from "./pii-fields";
function isPiiKey(key: string): boolean {
const lower = key.toLowerCase();
return PII_SUBSTRINGS.some((s) => lower.includes(s));
}
function containsPiiSubstring(s: string): boolean {
const lower = s.toLowerCase();
return PII_SUBSTRINGS.some((sub) => lower.includes(sub));
}
function scrubAttributes(attrs: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(attrs)) {
out[key] = isPiiKey(key) ? "[redacted]" : value;
}
return out;
}
/** Runs FIRST in the span processor chain so downstream exporters see scrubbed attributes. */
export class PiiScrubSpanProcessor implements SpanProcessor {
forceFlush(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
onStart(_span: Span): void {
// no-op
}
onEnd(span: ReadableSpan): void {
const scrubbed = scrubAttributes(span.attributes as Record<string, unknown>);
Object.assign(span.attributes, scrubbed);
}
}
/** Runs FIRST in the log processor chain. Strips PII from attributes AND from body strings. */
export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
forceFlush(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
onEmit(record: LogRecord): void {
if (record.attributes) {
const scrubbed = scrubAttributes(record.attributes as Record<string, unknown>);
Object.assign(record.attributes, scrubbed);
}
if (typeof record.body === "string" && containsPiiSubstring(record.body)) {
record.body = "[redacted]";
}
}
}
- Step 5: Run → PASS
pnpm --filter @repo/core-shared test pii-scrub-processor.test
- Step 6: Commit
git add packages/core-shared/src/instrumentation/otel/pii-fields.ts \
packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts \
packages/core-shared/src/instrumentation/otel/pii-scrub-processor.test.ts
git commit -m "feat(core-shared): PII scrub processors for spans + log records"
Task 5.4: Wire PII scrub processors into init helper
Files:
-
Modify:
packages/core-shared/src/instrumentation/otel/init-server-node.ts -
Step 1: Update init-server-node to wire PII scrubbers FIRST
In init-server-node.ts, add the PII processors as the FIRST entries in spanProcessors and logRecordProcessors:
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
// inside initOtelServerNode:
const spanProcessors = bridge.spanProcessor
? [new PiiScrubSpanProcessor(), new BatchSpanProcessor(bridge.spanProcessor as never)]
: [new PiiScrubSpanProcessor()];
const logRecordProcessors = bridge.logRecordProcessor
? [new PiiScrubLogRecordProcessor(), new BatchLogRecordProcessor(bridge.logRecordProcessor as never)]
: [new PiiScrubLogRecordProcessor()];
- Step 2: Run tests
pnpm --filter @repo/core-shared test
Expected: all pass.
- Step 3: Commit
git add packages/core-shared/src/instrumentation/otel/init-server-node.ts
git commit -m "feat(core-shared): wire PII scrub processors FIRST in OTel pipeline"
Task 5.5: Delete remaining sentry/ directory + update barrel
Files:
-
Delete:
packages/core-shared/src/instrumentation/sentry/scrub.ts+ test -
Delete: any other remaining files under
packages/core-shared/src/instrumentation/sentry/(the directory should be empty after this) -
Modify:
packages/core-shared/src/instrumentation/index.ts -
Modify:
packages/core-shared/package.json(drop sentry/* subpath exports) -
Step 1: List remaining sentry directory contents
ls packages/core-shared/src/instrumentation/sentry/
After Phase 3 the directory contains scrub.ts, scrub.test.ts, init-server.ts, init-client.ts, init-server-node.ts, init-client-react.ts, and their tests. The init files contain browser/client setup and stay — those are still used by browser-side Sentry SDK init (per scope: server-only migration). But the test files for init-server.ts / init-server-node.ts are about Sentry SDK init for the SERVER, which is being replaced by OTel. Decision: keep all 4 init files (they're imported by app-level init scripts), delete only scrub.ts + scrub.test.ts.
Actually, re-reading: the spec §8.3 says "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." But the init-server-node.ts in sentry/ is for the OLD Sentry-direct server init that's now replaced by OTel. The init-client.ts / init-client-react.ts are still used for browser-side Sentry SDK init — but the spec says server-only scope and browser keeps Sentry SDK directly. The browser apps' init paths consume those init helpers.
Resolution: check whether each file is referenced from any app or other code:
grep -rn "instrumentation/sentry/init-server\|instrumentation/sentry/init-client" apps/ packages/ 2>/dev/null
For each referenced file, KEEP it (browser side). For unreferenced files (likely scrub.ts after Phase 5, possibly init-server.ts / init-server-node.ts after Phase 2's binder swap), DELETE.
Most likely outcome: delete scrub.ts, scrub.test.ts, init-server.ts, init-server-node.ts, plus their tests. Keep init-client.ts, init-client-react.ts, and their tests (browser).
- Step 2: Delete confirmed-orphaned files
rm packages/core-shared/src/instrumentation/sentry/scrub.ts
rm packages/core-shared/src/instrumentation/sentry/scrub.test.ts
# Plus any other files identified in Step 1 as unreferenced
- Step 3: Update barrel exports
In packages/core-shared/src/instrumentation/index.ts, remove the line(s) that re-export ./sentry/scrub. Keep the re-exports for the browser init-client* paths.
- Step 4: Drop subpath exports
In packages/core-shared/package.json's exports block, remove the entries for ./instrumentation/sentry/scrub. Keep the entries for ./instrumentation/sentry/init-client and ./instrumentation/sentry/init-client-react (browser init paths).
- Step 5: Run gates
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green.
- Step 6: Commit
git add -A
git commit -m "refactor(core-shared): delete Sentry scrub + orphaned server-init files (replaced by OTel processors)"
Task 5.6: Final ESLint allowlist shape
Files:
-
Modify:
packages/core-eslint/base.js -
Step 1: Restate the allowlist
In packages/core-eslint/base.js, update the @sentry/* allowlist to its final shape:
- Allowed paths:
**/instrumentation/otel/sentry-bridge.ts,**/instrumentation/sentry/init-client*.{ts,js},**/sentry/*.config.{ts,mjs,js}, app-levelinstrumentation*.{ts,mjs}/next.config.{mjs}/vite.config.{ts}files. - Disallowed: everywhere else (R40 unchanged in spirit).
Update the OTel SDK rule block (R52 from Task 1.5) to match this scope:
-
Allowed paths for
@opentelemetry/sdk-*,@opentelemetry/exporter-*,@opentelemetry/instrumentation-*,@opentelemetry/resources,@opentelemetry/semantic-conventions:**/instrumentation/otel/**, app-level init paths. -
@opentelemetry/api,@opentelemetry/api-logsare unrestricted incore-shared/instrumentation/. -
Step 2: Run lint
pnpm lint
Expected: 0 errors.
- Step 3: Commit
git add packages/core-eslint/base.js
git commit -m "refactor(core-eslint): finalize OTel + Sentry import allowlist (R40 + R52)"
Task 5.7: Rename no-sentry.ts → no-instrumentation.ts in core-testing
Files:
-
Rename:
packages/core-testing/src/setup/no-sentry.ts→no-instrumentation.ts -
Rename:
packages/core-testing/src/setup/no-sentry.test.ts→no-instrumentation.test.ts -
Modify:
packages/core-testing/package.json -
Step 1: Rename files
git mv packages/core-testing/src/setup/no-sentry.ts \
packages/core-testing/src/setup/no-instrumentation.ts
git mv packages/core-testing/src/setup/no-sentry.test.ts \
packages/core-testing/src/setup/no-instrumentation.test.ts
- Step 2: Update content to mock OTel SDK too
In packages/core-testing/src/setup/no-instrumentation.ts, the existing Sentry mocks stay. Add OTel SDK mocks:
import { vi } from "vitest";
// Existing Sentry mocks:
vi.mock("@sentry/nextjs", () => ({ /* ... existing shape ... */ }));
vi.mock("@sentry/node", () => ({ /* ... existing shape ... */ }));
vi.mock("@sentry/react", () => ({ /* ... existing shape ... */ }));
// New OTel SDK mocks — prevent real SDK init in vitest runs:
vi.mock("@opentelemetry/sdk-node", () => ({
NodeSDK: class { start() {} shutdown() { return Promise.resolve(); } },
}));
vi.mock("@sentry/opentelemetry", () => ({
SentrySpanProcessor: class { onStart() {} onEnd() {} forceFlush() { return Promise.resolve(); } shutdown() { return Promise.resolve(); } },
SentryLogRecordProcessor: class { onEmit() {} forceFlush() { return Promise.resolve(); } shutdown() { return Promise.resolve(); } },
}));
- Step 3: Update subpath export
In packages/core-testing/package.json exports block:
"./setup/no-instrumentation": "./src/setup/no-instrumentation.ts",
"./setup/no-sentry": "./src/setup/no-instrumentation.ts"
The old name aliases to the new file for one release.
- Step 4: Update test file
In packages/core-testing/src/setup/no-instrumentation.test.ts, update any references to no-sentry in test descriptions to no-instrumentation.
- Step 5: Run tests
pnpm --filter @repo/core-testing test
- Step 6: Commit
git add packages/core-testing/src/setup/no-instrumentation.ts \
packages/core-testing/src/setup/no-instrumentation.test.ts \
packages/core-testing/package.json
git commit -m "refactor(core-testing): no-sentry → no-instrumentation (mocks OTel too)"
Task 5.8: Write ADR-017
Files:
-
Create:
docs/decisions/adr-017-opentelemetry-migration.md -
Step 1: Look at an existing ADR for format
ls docs/decisions/
head -50 docs/decisions/adr-016-realtime-layer.md
- Step 2: Write ADR-017
Create docs/decisions/adr-017-opentelemetry-migration.md with these sections:
# ADR-017 — OpenTelemetry Migration
**Status:** Accepted
**Date:** 2026-05-11
**Spec:** docs/superpowers/specs/2026-05-11-opentelemetry-migration-design.md
**Plan:** docs/superpowers/plans/2026-05-11-opentelemetry-migration.md
**Supersedes (impl section):** ADR-014
## Context
ADR-014 established vendor-neutral `ITracer` + `ILogger` interfaces with Sentry as the active backend. The interface decisions (R31–R51) have held up; what coupled to a vendor was the **substrate**: `SentryTracer` and `SentryLogger` called Sentry SDK methods directly. Swapping vendors required rewriting every `*Tracer`/`*Logger` pair.
This ADR migrates the substrate to OpenTelemetry: code emits OTel spans, logs, and metrics; exporters route to one or more backends. Sentry is wired as the (initially only) exporter via `@sentry/opentelemetry`. Swapping vendors becomes an exporter swap.
## Decision
1. **OTel SDK as substrate.** Server-side `ITracer` and `ILogger` impls use `@opentelemetry/api` and `@opentelemetry/api-logs` respectively. New `IMetrics` signal added via OTel metrics API.
2. **Sentry-as-exporter.** `@sentry/opentelemetry` provides `SentrySpanProcessor` + `SentryLogRecordProcessor`. They consume OTel signals and forward to Sentry. Sentry's UI experience is preserved (minus some browser-side richness, addressed below).
3. **Server-only scope.** Browser keeps Sentry SDK directly. Replay + session-error correlation stay native. Future spec extends OTel to browser when warranted.
4. **Pure OTel Logs API for the logger.** `OtelLogger` emits via `@opentelemetry/api-logs`. Trade-off: slightly degraded Sentry-native error UX (stack normalization, breadcrumb buffer) in exchange for swap-by-exporter vendor neutrality.
5. **Breadcrumbs → span events.** `ILogger.addBreadcrumb` attaches to the active OTel span as an event. Native OTel pattern.
6. **`setUser` per-span.** Sets `user.id` as a span attribute on the active span. R36 preserved (id only; no email/username).
7. **PII scrubbing migrated.** From Sentry's `beforeSend`/`beforeSendTransaction` hooks to OTel `SpanProcessor` + `LogRecordProcessor` impls. Processors run BEFORE the Sentry exporter, so PII is stripped at the OTel layer regardless of downstream exporter.
8. **R52 new ESLint rule.** `@opentelemetry/sdk-*`, `@opentelemetry/exporter-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions` restricted to `**/instrumentation/otel/**` and app init paths. `@opentelemetry/api` and `@opentelemetry/api-logs` are unrestricted within `core-shared/instrumentation/`.
9. **`bindSentryInstrumentation` renamed to `bindOtelInstrumentation`** with a deprecation alias.
10. **`IMetrics` synchronous-only.** Three methods: `counter`, `histogram`, `gauge`. `gauge` uses `UpDownCounter` under the hood; true "set" gauge semantics require an `ObservableGauge` with a periodic callback, deferred to a v2 metrics interface.
## Alternatives considered
- **Keep Sentry SDK directly.** Rejected — couples impl to Sentry forever.
- **OTel SDK + keep Sentry-direct for `captureException`.** Rejected — partial vendor swap re-introduces lock-in for the error path.
- **Migrate browser too.** Rejected — OTel-Browser maturity in 2026 is good for traces but Sentry's browser SDK has features (replay, native error correlation) that don't yet have OTel equivalents.
## Consequences
**Positive:**
- Vendor swaps are exporter swaps. Adding Honeycomb / Datadog / Grafana Cloud / Tempo is just adding their exporter alongside Sentry's.
- Auto-instrumentations (HTTP, undici, pg) reduce manual span boilerplate.
- New `IMetrics` signal available; metrics call sites can land per-feature opportunistically.
**Negative:**
- Sentry-native error UX is slightly degraded (errors arrive as OTel log records instead of native Sentry events). Acceptable per vendor-neutrality goal.
- Breadcrumb semantics shift from buffered cross-span to per-span events. Acceptable.
- Browser is still Sentry-direct — observability stack is asymmetric server vs. browser until a future browser migration.
- OTel SDK adds dependency surface (~10 new packages in `core-shared`).
## Relationship to ADR-014
ADR-014's interface decisions (R31–R51) remain authoritative. This ADR supersedes only the implementation section (Sentry SDK direct calls → OTel SDK). ADR-014 keeps a "Status: Superseded for impl by ADR-017" header.
- Step 3: Commit
git add docs/decisions/adr-017-opentelemetry-migration.md
git commit -m "docs(adr): ADR-017 OpenTelemetry migration"
Task 5.9: ADR-014 status header + doc refreshes
Files:
-
Modify:
docs/decisions/adr-014-instrumentation-sentry.md -
Modify:
CLAUDE.md -
Modify:
AGENTS.md -
Modify:
docs/architecture/dependency-flow.md -
Modify:
docs/architecture/vertical-feature-spec.md -
Modify:
docs/architecture/di-explainer.html -
Modify:
docs/architecture/data-flow-explainer.html -
Step 1: Update ADR-014 status
At the top of docs/decisions/adr-014-instrumentation-sentry.md, just after the **Status: Accepted** line, add:
**Status (revised):** Superseded by ADR-017 for the implementation layer. The interface decisions (R31–R51) remain authoritative.
- Step 2: Update CLAUDE.md
In CLAUDE.md, find the "Instrumentation lives in core-shared/instrumentation/" bullet. Update to reflect OTel substrate:
- **Instrumentation lives in `core-shared/instrumentation/`** — three interfaces (`ITracer`, `ILogger`, `IMetrics`), three implementation pairs (`Noop*`, `Otel*`, and `Recording*` from `core-testing`). Feature packages MUST NOT import `@opentelemetry/sdk-*` or `@sentry/*` directly (R40 + R52, eslint-enforced); the vendor-neutral `@opentelemetry/api` family is the import surface for advanced cases.
- Step 3: Update AGENTS.md
Find equivalent instrumentation references in AGENTS.md. Update to mention OTel as the substrate and the three signals.
- Step 4: Update dependency-flow.md and vertical-feature-spec.md
Grep for "Sentry" mentions:
grep -n "Sentry\|bindSentryInstrumentation" docs/architecture/dependency-flow.md docs/architecture/vertical-feature-spec.md
For each match, update to reflect: substrate is OTel; Sentry is one exporter; bindOtelInstrumentation is the binder name.
- Step 5: Update HTML explainers
In docs/architecture/di-explainer.html and data-flow-explainer.html, find references to bindSentryInstrumentation or "Sentry SDK". Update to "OTel SDK with Sentry exporter".
- Step 6: Commit
git add docs/decisions/adr-014-instrumentation-sentry.md \
CLAUDE.md AGENTS.md \
docs/architecture/dependency-flow.md \
docs/architecture/vertical-feature-spec.md \
docs/architecture/di-explainer.html \
docs/architecture/data-flow-explainer.html
git commit -m "docs: refresh architecture references for OTel migration"
Task 5.10: Phase 5 + final verification gate
- Step 1: Run all gates
pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries
Expected: all green. The PII scrub processor tests cover R31–R38 substring redaction at the new layer; the integration test covers end-to-end span+log emission with scrubbing applied before exporter.
- Step 2: Manual smoke test (optional, requires SENTRY_DSN)
If you have a WEB_NEXT_SENTRY_DSN set, start pnpm dev --filter @repo/web-next, trigger a tRPC call that throws an error (any error path), and confirm in Sentry UI that:
-
A span appears with the expected
span.opattribute. -
An error event appears with stack trace.
-
No PII fields appear in the captured attributes.
-
Step 3: No commit — verification gate.
Notes for the executing agent
- Phases 1 → 2 → 3 → 4 → 5 are sequenced. Don't start a phase until the previous one's gates are green.
- The most uncertain piece is Phase 1 Task 1.3 (Sentry bridge) —
@sentry/opentelemetryversion compatibility with the@sentry/nextjsalready in tree matters. If the version pin is wrong, the bridge module may not export the expectedSentrySpanProcessor/SentryLogRecordProcessorclasses. Check the actual installed Sentry version (@sentry/nextjspeer dep) and align. - The "Sentry metrics not yet wired" note in Phase 4 (Task 4.5 Step 4): Sentry's OTel metrics support is experimental as of 2025-2026. Treating the metrics exporter as
nullfor now keeps the migration shippable; metrics still flow to the OTel API but not exported anywhere until a real metric exporter is added. Document this as a known follow-up. - Browser-side Sentry SDK init files in
core-shared/instrumentation/sentry/init-client*.tsstay — they're consumed by app-level browser instrumentation scripts. Don't delete them in Task 5.5. - Commit cadence: ~25 commits across the five phases. Each commit should leave the repo in a green-gate state.
- ESLint allowlist evolution: each phase narrows or restates a piece of the
@sentry/*allowlist. The final shape in Task 5.6 should be reachable by a chain of small narrowings, not a single big rewrite.