Files
agentic-dev/docs/superpowers/plans/2026-05-11-opentelemetry-migration.md
Danijel Martinek 76870816d1 docs(plan): OpenTelemetry migration (5 phases, ~30 tasks, ~25 commits)
Implementation plan for the spec at docs/superpowers/specs/2026-05-11-
opentelemetry-migration-design.md. Five phases:

- Phase 1: OTel SDK infrastructure (resource builder + Sentry-as-exporter
  bridge + NodeSDK init helper + ESLint allowlist for OTel SDK packages).
  No behavior swap yet.
- Phase 2 (TDD): OtelTracer impl using @opentelemetry/api; bind-sentry-
  instrumentation renamed to bind-otel-instrumentation with deprecation
  alias; delete SentryTracer.
- Phase 3 (TDD): OtelLogger impl using @opentelemetry/api-logs;
  LogRecordProcessor wired into init helper; delete SentryLogger.
  Breadcrumbs become span events; setUser sets user.id span attribute.
- Phase 4 (TDD): New IMetrics interface + Noop/Otel/Recording impls;
  MetricsProtocol added to bind-protocols; BindContext.metrics? optional
  field. Sentry metrics exporter deferred (experimental).
- Phase 5 (TDD): HTTP/undici/pg auto-instrumentations; PII scrub
  processors (PiiScrubSpanProcessor + PiiScrubLogRecordProcessor) run
  FIRST in OTel pipeline; delete sentry/scrub.ts + orphaned init files;
  core-testing/setup/no-sentry.ts → no-instrumentation.ts (mocks both
  Sentry and OTel SDK); ADR-017 + ADR-014 status header; doc refreshes.

Total: ~30 tasks across 6 phases (including Phase 0 read-first), ~25
expected commits.

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

2488 lines
88 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (R31R51) 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<string, string> = {
"service.name": opts.serviceName,
"deployment.environment.name": opts.environment,
};
if (opts.serviceVersion) attrs["service.version"] = opts.serviceVersion;
if (opts.namespace) attrs["service.namespace"] = opts.namespace;
return resourceFromAttributes(attrs);
}
```
- [ ] **Step 4: Run test → PASS**
```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 78100 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<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
const attributes: Record<string, string | number | boolean> = {};
if (opts.attributes) {
for (const [k, v] of Object.entries(opts.attributes)) {
if (v !== null) attributes[k] = v;
}
}
if (opts.op) attributes["span.op"] = opts.op;
return this.tracer.startActiveSpan(
opts.name,
{ kind: SpanKind.INTERNAL, attributes },
async (otelSpan) => {
const adapter: ISpan = {
setAttribute(key: string, value: AttributeValue) {
if (value !== null) otelSpan.setAttribute(key, value);
},
setStatus(status: "ok" | "error", message?: string) {
otelSpan.setStatus({
code: status === "ok" ? SpanStatusCode.OK : SpanStatusCode.ERROR,
message,
});
},
};
try {
return await fn(adapter);
} catch (err) {
otelSpan.recordException(err as Error);
otelSpan.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
otelSpan.end();
}
},
);
}
}
```
- [ ] **Step 4: Run test → PASS**
```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<ITracer>(INSTRUMENTATION_SYMBOLS.ITracer).toConstantValue(tracer);
container.bind<ILogger>(INSTRUMENTATION_SYMBOLS.ILogger).toConstantValue(logger);
return { tracer, logger };
}
```
- [ ] **Step 4: Update test file**
In `packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.test.ts`:
- Update imports: `bindSentryInstrumentation``bindOtelInstrumentation`.
- Update test names from "binds Sentry instrumentation" to "binds OTel instrumentation".
- Update assertions: tracer should now be `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<string, string>): Record<string, string> {
if (!tags) return {};
return Object.fromEntries(Object.entries(tags).map(([k, v]) => [`tag.${k}`, v]));
}
function flattenExtras(extras?: Record<string, unknown>): Record<string, string> {
if (!extras) return {};
return Object.fromEntries(
Object.entries(extras).map(([k, v]) => [
`extra.${k}`,
typeof v === "string" ? v : JSON.stringify(v),
]),
);
}
```
- [ ] **Step 4: Run test → PASS**
```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<string, MetricAttributeValue>,
): void;
/** Distribution. Use for measured quantities (latency, payload size). */
histogram(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
/**
* Point-in-time value. Uses UpDownCounter under the hood — true "set" gauge
* semantics require an ObservableGauge with a periodic callback, which is a
* future v2 interface bump.
*/
gauge(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
}
```
- [ ] **Step 4: Create NoopMetrics**
Create `packages/core-shared/src/instrumentation/noop-metrics.ts`:
```ts
import type { IMetrics, MetricAttributeValue } from "./metrics.interface";
export class NoopMetrics implements IMetrics {
counter(_name: string, _value?: number, _attributes?: Record<string, MetricAttributeValue>): void {}
histogram(_name: string, _value: number, _attributes?: Record<string, MetricAttributeValue>): void {}
gauge(_name: string, _value: number, _attributes?: Record<string, MetricAttributeValue>): void {}
}
```
- [ ] **Step 5: Run → PASS**
```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<void> {
await provider.forceFlush();
}
describe("OtelMetrics", () => {
it("counter increments by the given value", async () => {
const m = new OtelMetrics();
m.counter("test_counter", 5, { route: "/" });
m.counter("test_counter", 3, { route: "/" });
await flush();
const exported = exporter.getMetrics();
const counter = exported[0]!.scopeMetrics[0]!.metrics.find((mm) => mm.descriptor.name === "test_counter");
expect(counter).toBeDefined();
expect(counter!.dataPoints[0]!.value).toBe(8);
});
it("histogram records values", async () => {
const m = new OtelMetrics();
m.histogram("test_latency", 100);
m.histogram("test_latency", 200);
await flush();
const exported = exporter.getMetrics();
const histogram = exported[0]!.scopeMetrics[0]!.metrics.find((mm) => mm.descriptor.name === "test_latency");
expect(histogram).toBeDefined();
});
it("gauge accumulates as UpDownCounter (synchronous emit)", async () => {
const m = new OtelMetrics();
m.gauge("test_queue_depth", 17);
await flush();
const exported = exporter.getMetrics();
const gauge = exported[0]!.scopeMetrics[0]!.metrics.find((mm) => mm.descriptor.name === "test_queue_depth");
expect(gauge).toBeDefined();
});
});
```
- [ ] **Step 2: Run → FAIL**
```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<string, Counter>();
private readonly histograms = new Map<string, Histogram>();
private readonly gauges = new Map<string, UpDownCounter>();
counter(name: string, value: number = 1, attributes?: Record<string, MetricAttributeValue>): void {
let c = this.counters.get(name);
if (!c) {
c = this.meter.createCounter(name);
this.counters.set(name, c);
}
c.add(value, attributes);
}
histogram(name: string, value: number, attributes?: Record<string, MetricAttributeValue>): void {
let h = this.histograms.get(name);
if (!h) {
h = this.meter.createHistogram(name);
this.histograms.set(name, h);
}
h.record(value, attributes);
}
gauge(name: string, value: number, attributes?: Record<string, MetricAttributeValue>): void {
let g = this.gauges.get(name);
if (!g) {
g = this.meter.createUpDownCounter(name);
this.gauges.set(name, g);
}
g.add(value, attributes);
}
}
```
- [ ] **Step 4: Run → PASS**
```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<string, MetricAttributeValue> }
| { kind: "histogram"; name: string; value: number; attributes?: Record<string, MetricAttributeValue> }
| { kind: "gauge"; name: string; value: number; attributes?: Record<string, MetricAttributeValue> };
export class RecordingMetrics implements IMetrics {
public recorded: RecordedMetric[] = [];
counter(name: string, value: number = 1, attributes?: Record<string, MetricAttributeValue>): void {
this.recorded.push({ kind: "counter", name, value, attributes });
}
histogram(name: string, value: number, attributes?: Record<string, MetricAttributeValue>): void {
this.recorded.push({ kind: "histogram", name, value, attributes });
}
gauge(name: string, value: number, attributes?: Record<string, MetricAttributeValue>): void {
this.recorded.push({ kind: "gauge", name, value, attributes });
}
reset(): void {
this.recorded = [];
}
}
```
- [ ] **Step 4: Update core-testing barrel**
In `packages/core-testing/src/instrumentation/index.ts`, append:
```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<IMetrics>(INSTRUMENTATION_SYMBOLS.IMetrics).toConstantValue(metrics);
return { tracer, logger, metrics };
}
```
- [ ] **Step 4: Wire OtelMetrics in bind-otel + MeterProvider in init**
In `packages/core-shared/src/instrumentation/otel/init-server-node.ts`, add the MeterProvider integration. The Sentry bridge in Phase 1 didn't include a metric exporter — add one now via the same bridge pattern. For now, the bridge returns `null` for metrics (Sentry metrics are experimental); the OTel SDK boots with no metric reader unless one is explicitly configured.
Specifically, register a `PeriodicExportingMetricReader` only if `bridge.metricExporter` is set. Update `sentry-bridge.ts` to also return a `metricExporter: null` (placeholder):
In `packages/core-shared/src/instrumentation/otel/sentry-bridge.ts`, update the return type:
```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<IMetrics>(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<string, string | number | boolean>,
): void;
histogram(
name: string,
value: number,
attributes?: Record<string, string | number | boolean>,
): void;
gauge(
name: string,
value: number,
attributes?: Record<string, string | number | boolean>,
): void;
};
```
- [ ] **Step 2: Make IMetrics extend MetricsProtocol**
In `packages/core-shared/src/instrumentation/metrics.interface.ts`, update the interface declaration:
```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<Bus, Realtime, RealtimeReg, Metrics> & {
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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(attrs)) {
out[key] = isPiiKey(key) ? "[redacted]" : value;
}
return out;
}
/** Runs FIRST in the span processor chain so downstream exporters see scrubbed attributes. */
export class PiiScrubSpanProcessor implements SpanProcessor {
forceFlush(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
onStart(_span: Span): void {
// no-op
}
onEnd(span: ReadableSpan): void {
const scrubbed = scrubAttributes(span.attributes as Record<string, unknown>);
Object.assign(span.attributes, scrubbed);
}
}
/** Runs FIRST in the log processor chain. Strips PII from attributes AND from body strings. */
export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
forceFlush(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
onEmit(record: LogRecord): void {
if (record.attributes) {
const scrubbed = scrubAttributes(record.attributes as Record<string, unknown>);
Object.assign(record.attributes, scrubbed);
}
if (typeof record.body === "string" && containsPiiSubstring(record.body)) {
record.body = "[redacted]";
}
}
}
```
- [ ] **Step 5: Run → PASS**
```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 (R31R51) 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 (R31R51) 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 (R31R51) 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 R31R38 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.