# 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.ts` - `packages/core-shared/src/instrumentation/logger.interface.ts` - `packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts` - `packages/core-shared/src/instrumentation/sentry/sentry-logger.ts` - `packages/core-shared/src/instrumentation/sentry/scrub.ts` - `packages/core-shared/src/instrumentation/sentry/pii-fields.ts` - `packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts` - `packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts` - `packages/core-shared/src/instrumentation/with-span.ts` - `packages/core-shared/src/instrumentation/with-capture.ts` - `packages/core-shared/src/instrumentation/reported-flag.ts` - `packages/core-eslint/base.js` (specifically the `@sentry/*` allowlist rule block, R40) - `apps/web-next/src/server/bind-production.ts` — see `resolveInstrumentation()` - `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`** ```bash 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: ```json "@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): ```json "./instrumentation/otel": "./src/instrumentation/otel/index.ts", "./instrumentation/otel/init-server-node": "./src/instrumentation/otel/init-server-node.ts" ``` - [ ] **Step 4: Install** ```bash pnpm install ``` Expected: dependencies resolve cleanly. No errors. - [ ] **Step 5: Commit** ```bash 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`: ```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** ```bash 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`: ```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 = { "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** ```bash pnpm --filter @repo/core-shared test resource.test ``` Expected: PASS, 3 tests. - [ ] **Step 5: Commit** ```bash 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`: ```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** ```bash 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`: ```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** ```bash pnpm --filter @repo/core-shared test sentry-bridge.test ``` Expected: PASS, 2 tests. - [ ] **Step 5: Commit** ```bash 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`: ```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** ```bash 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`: ```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** ```bash pnpm --filter @repo/core-shared test init-server-node.test ``` Expected: PASS, 2 tests. - [ ] **Step 5: Commit** ```bash 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`: ```ts export { initOtelServerNode, type InitOtelServerNodeOpts } from "./init-server-node"; export { buildResource, type BuildResourceOpts } from "./resource"; ``` - [ ] **Step 2: Read current `@sentry/*` allowlist in `core-eslint/base.js`** ```bash 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/**`: ```js // 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** ```bash pnpm lint ``` Expected: 0 errors, only pre-existing warnings. - [ ] **Step 5: Commit** ```bash 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** ```bash 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`: ```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** ```bash 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`: ```ts import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api"; import type { ITracer, ISpan, SpanOpts, AttributeValue } from "../tracer.interface"; export class OtelTracer implements ITracer { private readonly tracer = trace.getTracer("@repo/core-shared", "1.0.0"); async startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { const attributes: Record = {}; 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** ```bash pnpm --filter @repo/core-shared test otel-tracer.test ``` Expected: PASS, 5 tests. - [ ] **Step 5: Commit** ```bash 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`** ```bash 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** ```bash 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: ```ts 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(INSTRUMENTATION_SYMBOLS.ITracer).toConstantValue(tracer); container.bind(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 `OtelTracer` instance, not `SentryTracer`. 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: ```ts export { bindSentryInstrumentation, type BindSentryOpts, } from "./di/bind-sentry-instrumentation"; ``` with: ```ts 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** ```bash pnpm --filter @repo/core-shared test bind-otel-instrumentation ``` Expected: PASS. - [ ] **Step 7: Commit** ```bash 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/...` and `apps/web-tanstack/...` - [ ] **Step 1: Find call sites** ```bash 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** ```bash pnpm lint && pnpm typecheck ``` Expected: 0 errors. - [ ] **Step 4: Commit** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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): ```json "@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** ```bash pnpm install ``` - [ ] **Step 3: Commit** ```bash 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`: ```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** ```bash 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`: ```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): Record { if (!tags) return {}; return Object.fromEntries(Object.entries(tags).map(([k, v]) => [`tag.${k}`, v])); } function flattenExtras(extras?: Record): Record { 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** ```bash pnpm --filter @repo/core-shared test otel-logger.test ``` Expected: PASS, 6 tests. - [ ] **Step 5: Commit** ```bash 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: ```ts 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: ```ts import { SentryLogger } from "../sentry/sentry-logger"; // Replaced in Phase 3 with OtelLogger // ... const logger = new SentryLogger(); // Phase 3 replaces this with OtelLogger ``` with: ```ts import { OtelLogger } from "../otel/otel-logger"; // ... const logger = new OtelLogger(); ``` - [ ] **Step 3: Run tests** ```bash 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** ```bash 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** ```bash 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** ```bash pnpm lint && pnpm typecheck && pnpm test ``` Expected: all green. - [ ] **Step 4: Commit** ```bash 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** ```bash 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`: ```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** ```bash pnpm install ``` - [ ] **Step 3: Commit** ```bash 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`: ```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** ```bash pnpm --filter @repo/core-shared test noop-metrics.test ``` - [ ] **Step 3: Create the interface** Create `packages/core-shared/src/instrumentation/metrics.interface.ts`: ```ts export type MetricAttributeValue = string | number | boolean; export interface IMetrics { /** Monotonic counter. Use for event counts (signups, errors, requests). */ counter( name: string, value?: number, attributes?: Record, ): void; /** Distribution. Use for measured quantities (latency, payload size). */ histogram( name: string, value: number, attributes?: Record, ): void; /** * Point-in-time value. 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, ): void; } ``` - [ ] **Step 4: Create NoopMetrics** Create `packages/core-shared/src/instrumentation/noop-metrics.ts`: ```ts import type { IMetrics, MetricAttributeValue } from "./metrics.interface"; export class NoopMetrics implements IMetrics { counter(_name: string, _value?: number, _attributes?: Record): void {} histogram(_name: string, _value: number, _attributes?: Record): void {} gauge(_name: string, _value: number, _attributes?: Record): void {} } ``` - [ ] **Step 5: Run → PASS** ```bash pnpm --filter @repo/core-shared test noop-metrics.test ``` - [ ] **Step 6: Commit** ```bash 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`: ```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 { 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** ```bash pnpm --filter @repo/core-shared test otel-metrics.test ``` - [ ] **Step 3: Implement** Create `packages/core-shared/src/instrumentation/otel/otel-metrics.ts`: ```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(); private readonly histograms = new Map(); private readonly gauges = new Map(); counter(name: string, value: number = 1, attributes?: Record): 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): 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): 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** ```bash pnpm --filter @repo/core-shared test otel-metrics.test ``` - [ ] **Step 5: Commit** ```bash 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`: ```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** ```bash pnpm --filter @repo/core-testing test recording-metrics.test ``` - [ ] **Step 3: Implement** Create `packages/core-testing/src/instrumentation/recording-metrics.ts`: ```ts import type { IMetrics, MetricAttributeValue } from "@repo/core-shared/instrumentation"; export type RecordedMetric = | { kind: "counter"; name: string; value: number; attributes?: Record } | { kind: "histogram"; name: string; value: number; attributes?: Record } | { kind: "gauge"; name: string; value: number; attributes?: Record }; export class RecordingMetrics implements IMetrics { public recorded: RecordedMetric[] = []; counter(name: string, value: number = 1, attributes?: Record): void { this.recorded.push({ kind: "counter", name, value, attributes }); } histogram(name: string, value: number, attributes?: Record): void { this.recorded.push({ kind: "histogram", name, value, attributes }); } gauge(name: string, value: number, attributes?: Record): 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: ```ts export { RecordingMetrics, type RecordedMetric } from "./recording-metrics"; ``` - [ ] **Step 5: Run → PASS** ```bash pnpm --filter @repo/core-testing test recording-metrics.test ``` - [ ] **Step 6: Commit** ```bash 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`: ```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: ```ts 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`: ```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(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: ```ts 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`: ```ts import { OtelMetrics } from "../otel/otel-metrics"; // ... const metrics = new OtelMetrics(); if (container.isBound(INSTRUMENTATION_SYMBOLS.IMetrics)) { container.unbind(INSTRUMENTATION_SYMBOLS.IMetrics); } container.bind(INSTRUMENTATION_SYMBOLS.IMetrics).toConstantValue(metrics); return { tracer, logger, metrics }; ``` - [ ] **Step 5: Run tests** ```bash pnpm --filter @repo/core-shared test ``` Expected: all pass. - [ ] **Step 6: Commit** ```bash 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: ```ts export type MetricsProtocol = { counter( name: string, value?: number, attributes?: Record, ): void; histogram( name: string, value: number, attributes?: Record, ): void; gauge( name: string, value: number, attributes?: Record, ): void; }; ``` - [ ] **Step 2: Make IMetrics extend MetricsProtocol** In `packages/core-shared/src/instrumentation/metrics.interface.ts`, update the interface declaration: ```ts 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: ```ts 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 & { config: SanitizedConfig; }; ``` - [ ] **Step 4: Run typecheck** ```bash pnpm --filter @repo/core-shared typecheck ``` Expected: clean. - [ ] **Step 5: Run web-next + feature typechecks** ```bash pnpm typecheck ``` Expected: all green. The optional `metrics?` field is backward-compatible — existing call sites don't have to provide it. - [ ] **Step 6: Commit** ```bash 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** ```bash 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`: ```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** ```bash pnpm install ``` - [ ] **Step 3: Commit** ```bash 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`: ```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** ```bash 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** ```bash 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** ```bash 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: ```bash 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`: ```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** ```bash pnpm --filter @repo/core-shared test pii-scrub-processor.test ``` - [ ] **Step 4: Implement** Create `packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts`: ```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): Record { const out: Record = {}; 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 { return Promise.resolve(); } shutdown(): Promise { return Promise.resolve(); } onStart(_span: Span): void { // no-op } onEnd(span: ReadableSpan): void { const scrubbed = scrubAttributes(span.attributes as Record); 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 { return Promise.resolve(); } shutdown(): Promise { return Promise.resolve(); } onEmit(record: LogRecord): void { if (record.attributes) { const scrubbed = scrubAttributes(record.attributes as Record); Object.assign(record.attributes, scrubbed); } if (typeof record.body === "string" && containsPiiSubstring(record.body)) { record.body = "[redacted]"; } } } ``` - [ ] **Step 5: Run → PASS** ```bash pnpm --filter @repo/core-shared test pii-scrub-processor.test ``` - [ ] **Step 6: Commit** ```bash 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`: ```ts 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** ```bash pnpm --filter @repo/core-shared test ``` Expected: all pass. - [ ] **Step 3: Commit** ```bash 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** ```bash 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: ```bash 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** ```bash 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** ```bash pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries ``` Expected: all green. - [ ] **Step 6: Commit** ```bash 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-level `instrumentation*.{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-logs` are unrestricted in `core-shared/instrumentation/`. - [ ] **Step 2: Run lint** ```bash pnpm lint ``` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash 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** ```bash 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: ```ts 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: ```json "./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** ```bash pnpm --filter @repo/core-testing test ``` - [ ] **Step 6: Commit** ```bash 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** ```bash 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: ```markdown # 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** ```bash 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: ```markdown **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: ```bash 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** ```bash 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** ```bash 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.op` attribute. - 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/opentelemetry` version compatibility with the `@sentry/nextjs` already in tree matters. If the version pin is wrong, the bridge module may not export the expected `SentrySpanProcessor` / `SentryLogRecordProcessor` classes. Check the actual installed Sentry version (`@sentry/nextjs` peer 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 `null` for 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*.ts` stay — 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.