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>
2488 lines
88 KiB
Markdown
2488 lines
88 KiB
Markdown
# 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.
|