# 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<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 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<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 (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.
