From 76870816d176a6dda177eb47a2907479ebe3e8c5 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 11:15:33 +0200 Subject: [PATCH] docs(plan): OpenTelemetry migration (5 phases, ~30 tasks, ~25 commits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-05-11-opentelemetry-migration.md | 2487 +++++++++++++++++ 1 file changed, 2487 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-11-opentelemetry-migration.md diff --git a/docs/superpowers/plans/2026-05-11-opentelemetry-migration.md b/docs/superpowers/plans/2026-05-11-opentelemetry-migration.md new file mode 100644 index 0000000..2a038f6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-opentelemetry-migration.md @@ -0,0 +1,2487 @@ +# 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.