Merge branch 'worktree-opentelemetry-migration': OpenTelemetry migration (ADR-017)

This commit is contained in:
2026-05-11 12:59:20 +02:00
70 changed files with 2993 additions and 1102 deletions

View File

@@ -401,9 +401,12 @@ See `docs/guides/realtime.md` and `docs/decisions/adr-016-realtime-layer.md`.
## Instrumentation conventions
Substrate: **OpenTelemetry SDK** (ADR-017). Sentry is wired as the exporter via `@sentry/opentelemetry`. Vendor swaps are exporter swaps — feature code never touches Sentry or OTel SDK directly.
**Symbols (in `core-shared/instrumentation/symbols.ts`):**
- `INSTRUMENTATION_SYMBOLS.TRACER` — bound to `ITracer` (`NoopTracer` / `SentryTracer`)
- `INSTRUMENTATION_SYMBOLS.LOGGER` — bound to `ILogger` (`NoopLogger` / `SentryLogger`)
- `INSTRUMENTATION_SYMBOLS.ITracer` — bound to `ITracer` (`NoopTracer` / `OtelTracer`)
- `INSTRUMENTATION_SYMBOLS.ILogger` — bound to `ILogger` (`NoopLogger` / `OtelLogger`)
- `INSTRUMENTATION_SYMBOLS.IMetrics` — bound to `IMetrics` (`NoopMetrics` / `OtelMetrics`)
**Repository constructor signature (every feature):**
@@ -470,19 +473,18 @@ const wrappedCtrl = withSpan(
| Controller | `InputParseError` from `safeParse` failure (via `withCapture`) | Errors from use cases — flag set, `withCapture` bails |
| `defineErrorMiddleware` | Nothing — maps domain → TRPCError only | — |
**Boundary rule (eslint-enforced, R40):**
Feature packages MUST NOT `import "@sentry/*"`. Allowlist:
- `**/instrumentation/sentry/**` (core-shared)
- `**/instrumentation/di/bind-sentry-instrumentation.{ts,test.ts}`
- `**/setup/no-sentry.{ts,js}` + the test guard
- `apps/*/instrumentation*.{ts,mjs,js}`
- `apps/*/next.config.{mjs,ts,js}`
- `apps/*/vite.config.{ts,mjs,js}`
**Boundary rules (eslint-enforced, R40 + R52):**
Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"`. Allowlists:
- R40 (`@sentry/*`): `**/instrumentation/otel/sentry-bridge.{ts,js}`, `**/instrumentation/sentry/init-client*.{ts,js}`, `**/instrumentation/sentry/init-server*.{ts,js}`, `**/setup/no-instrumentation.{ts,js}`, `apps/*/instrumentation*.{ts,mjs,js}`, `apps/*/next.config.{mjs,ts,js}`, `apps/*/vite.config.{ts,mjs,js}`
- R52 (`@opentelemetry/sdk-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions`, `@sentry/opentelemetry`): `**/instrumentation/otel/**`
The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`) are unrestricted within `core-shared/instrumentation/`.
**Test rules:**
- Default to `NoopTracer` / `NoopLogger` (constructor defaults)
- Assert spans/captures by injecting `RecordingTracer` / `RecordingLogger` from `@repo/core-testing/instrumentation`
- Real `@sentry/*` SDK MUST NOT initialize during tests (guarded by `core-testing/setup/no-sentry.ts`)
- Default to `NoopTracer` / `NoopLogger` / `NoopMetrics` (constructor defaults)
- Assert spans/captures by injecting `RecordingTracer` / `RecordingLogger` / `RecordingMetrics` from `@repo/core-testing/instrumentation`
- Real Sentry SDK + OTel SDK MUST NOT initialize during tests (guarded by `core-testing/setup/no-instrumentation.ts`; old alias `no-sentry` kept for one release)
---

View File

@@ -61,12 +61,12 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`,
- **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev<Entities>()` function that uses the feature's existing factory
- **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders
- **App bootstrap** — Each app calls `bindAll()` from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent
- **Instrumentation lives in `core-shared/instrumentation/`** — Two interfaces (`ITracer`, `ILogger`), three implementations (`NoopTracer`/`NoopLogger`, `SentryTracer`/`SentryLogger`, and `RecordingTracer`/`RecordingLogger` from `core-testing`). Feature packages MUST NOT import `@sentry/*` directly (R40, eslint-enforced)
- **Instrumentation lives in `core-shared/instrumentation/`** — Three interfaces (`ITracer`, `ILogger`, `IMetrics`), three implementation pairs (`Noop*`, `Otel*`, and `Recording*` from `core-testing`). The OTel SDK is the substrate; Sentry is wired as the exporter via `@sentry/opentelemetry`. 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 (ADR-017)
- **Spans + capture composed at DI bind time** — Use cases + controllers wrapped via `withSpan(tracer, spanOpts, withCapture(logger, tags, factory(deps)))` inside `bind-production` / `bind-dev-seed`. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they call `this.tracer.startSpan(...)` and `this.logger.captureException(...)` inline per method because they own per-call attributes (R41, R42)
- **Capture at throw sites only, with double-report guard** — Repos capture infra errors inline; use cases + controllers capture via `withCapture` at bind time; `defineErrorMiddleware` never captures (R43, R44). Each error gets a non-enumerable `__sentryReported` flag the first time it's captured; `withCapture`, `SentryLogger`, and `RecordingLogger` all bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper at `core-shared/instrumentation/reported-flag.ts`)
- **PII handling is non-negotiable** — `sendDefaultPii: false` everywhere (R31, CI grep gate); replay default-masks all text/inputs/media (R34, R35, allowlist starts empty); `Sentry.setUser({ id })` only — no email/username (R36); `beforeSend` + `beforeSendTransaction` scrubbers strip emails/passwords/tokens/cookies/auth/IPs (R32, R33)
- **Capture at throw sites only, with double-report guard** — Repos capture infra errors inline; use cases + controllers capture via `withCapture` at bind time; `defineErrorMiddleware` never captures (R43, R44). Each error gets a non-enumerable `__sentryReported` flag the first time it's captured; `withCapture`, `OtelLogger`, and `RecordingLogger` all bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper at `core-shared/instrumentation/reported-flag.ts`)
- **PII handling is non-negotiable** — `sendDefaultPii: false` everywhere (R31, CI grep gate); replay default-masks all text/inputs/media (R34, R35, allowlist starts empty); `setUser({ id })` only — no email/username (R36); server-side PII scrubbing happens at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before any exporter sees the data (R32, R33, ADR-017 §7)
- **Three apps, three Sentry projects** — `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN`. Browser DSNs use `NEXT_PUBLIC_` (web-next) and `VITE_` (web-tanstack) prefixes
- **Instrumentation binding is orthogonal to repo binding** — `bindAll()`'s Rule 0 (DSN → Sentry vs Noop) is independent of `USE_DEV_SEED` / `NODE_ENV`. Run `pnpm dev` with `WEB_NEXT_SENTRY_DSN` set to test the integration locally
- **Instrumentation binding is orthogonal to repo binding** — `bindAll()`'s Rule 0 (DSN → OTel+Sentry vs Noop) is independent of `USE_DEV_SEED` / `NODE_ENV`. Run `pnpm dev` with `WEB_NEXT_SENTRY_DSN` set to test the integration locally
- **Cross-feature events go through `IEventBus` (E0)** — In-feature reactions are direct use-case calls, not bus publishes. The bus is for *crossing* feature boundaries (e.g. `auth``marketing-pages` welcome email)
- **Event contracts are public; handlers are private (E1)** — Publisher's `events/<x>.event.ts` is exported from the feature root barrel. Consumer's `events/handlers/on-<publisher>-<event>.handler.ts` is never re-exported (ESLint-enforced via `core-eslint/rules/no-handler-reexport`)
- **Jobs are for *deferred* work, not abstraction (J0)** — Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron). Feature packages enqueue via `IJobQueue` only — direct `payload.jobs.queue()` is ESLint-blocked outside `core-shared/jobs/`

View File

@@ -2,19 +2,22 @@
// CMS is server-only (Payload admin UI). No instrumentation-client.ts here —
// Payload admin UI bundling is opinionated and the public DSN flow is
// out-of-scope per spec §8.
//
// Initializes the OTel SDK here so PII scrub processors are active from the
// very first request — before bindAll() fires (C1 fix).
export async function register() {
if (
process.env["NEXT_RUNTIME"] === "nodejs" ||
process.env["NEXT_RUNTIME"] === "edge"
) {
const { initSentryServer } = await import(
"@repo/core-shared/instrumentation/sentry/init-server"
const { initOtelServerNode } = await import(
"@repo/core-shared/instrumentation/otel/init-server-node"
);
initSentryServer({
dsn: process.env["CMS_SENTRY_DSN"],
app: "cms",
release: process.env["VERCEL_GIT_COMMIT_SHA"],
initOtelServerNode({
dsn: process.env["CMS_SENTRY_DSN"] ?? "",
serviceName: "cms",
environment: process.env["NODE_ENV"] ?? "development",
});
}
}

View File

@@ -1,56 +0,0 @@
import { describe, it, expect } from "vitest";
import {
beforeSend,
beforeSendTransaction,
} from "@repo/core-shared/instrumentation/sentry/scrub";
describe("R38 — apps/cms PII scrubber", () => {
it("strips email/password/cookie/auth/IP from event payload", () => {
const event = {
extra: {
userEmail: "alice@example.com",
password: "p4$$w0rd",
ipAddress: "192.168.1.10",
note: "request from 10.0.0.1",
},
request: {
headers: {
Authorization: "Bearer secret",
"Set-Cookie": "session=abc",
"User-Agent": "Mozilla",
},
},
} as Parameters<typeof beforeSend>[0];
const result = beforeSend(event, {}) as {
extra: Record<string, string>;
request: { headers: Record<string, string> };
};
expect(result.extra["userEmail"]).toBe("[redacted]");
expect(result.extra["password"]).toBe("[redacted]");
expect(result.extra["ipAddress"]).toBe("[redacted]");
expect(result.extra["note"]).toContain("[redacted-ip]");
expect(result.request.headers["Authorization"]).toBe("[redacted]");
expect(result.request.headers["Set-Cookie"]).toBe("[redacted]");
expect(result.request.headers["User-Agent"]).toBe("Mozilla");
});
it("strips ?token / ?email / ?password / ?secret / ?signature from URLs", () => {
const event = {
request: {
url: "https://app/api/x?token=abc&email=a@b.c&password=p&secret=z&signature=s&safe=1",
},
transaction: "/foo?accessToken=t",
} as Parameters<typeof beforeSendTransaction>[0];
const result = beforeSendTransaction(event, {}) as {
request: { url: string };
transaction: string;
};
const url = decodeURIComponent(result.request.url);
const txn = decodeURIComponent(result.transaction);
for (const key of ["token", "email", "password", "secret", "signature"]) {
expect(url).toContain(`${key}=[redacted]`);
}
expect(url).toContain("safe=1");
expect(txn).toContain("accessToken=[redacted]");
});
});

View File

@@ -1,19 +1,22 @@
// apps/web-next/instrumentation.ts
// Next.js convention: this module runs once on server boot.
// Delegates to the centralized init helper in core-shared.
// Next.js convention: this module runs once on server boot (before any request handler).
// Initializes the OTel SDK here so PII scrub processors are active from the very first
// request — before bindAll() fires. Calling initOtelServerNode here (not inside bindAll)
// closes the startup window where @sentry/nextjs auto-instrumentation could send
// unscrubbed errors (C1 fix).
export async function register() {
if (
process.env["NEXT_RUNTIME"] === "nodejs" ||
process.env["NEXT_RUNTIME"] === "edge"
) {
const { initSentryServer } = await import(
"@repo/core-shared/instrumentation/sentry/init-server"
const { initOtelServerNode } = await import(
"@repo/core-shared/instrumentation/otel/init-server-node"
);
initSentryServer({
dsn: process.env["WEB_NEXT_SENTRY_DSN"],
app: "web-next",
release: process.env["VERCEL_GIT_COMMIT_SHA"],
initOtelServerNode({
dsn: process.env["WEB_NEXT_SENTRY_DSN"] ?? "",
serviceName: "web-next",
environment: process.env["NODE_ENV"] ?? "development",
});
}
}

View File

@@ -1,56 +0,0 @@
import { describe, it, expect } from "vitest";
import {
beforeSend,
beforeSendTransaction,
} from "@repo/core-shared/instrumentation/sentry/scrub";
describe("R38 — apps/web-next PII scrubber", () => {
it("strips email/password/cookie/auth/IP from event payload", () => {
const event = {
extra: {
userEmail: "alice@example.com",
password: "p4$$w0rd",
ipAddress: "192.168.1.10",
note: "request from 10.0.0.1",
},
request: {
headers: {
Authorization: "Bearer secret",
"Set-Cookie": "session=abc",
"User-Agent": "Mozilla",
},
},
} as Parameters<typeof beforeSend>[0];
const result = beforeSend(event, {}) as {
extra: Record<string, string>;
request: { headers: Record<string, string> };
};
expect(result.extra["userEmail"]).toBe("[redacted]");
expect(result.extra["password"]).toBe("[redacted]");
expect(result.extra["ipAddress"]).toBe("[redacted]");
expect(result.extra["note"]).toContain("[redacted-ip]");
expect(result.request.headers["Authorization"]).toBe("[redacted]");
expect(result.request.headers["Set-Cookie"]).toBe("[redacted]");
expect(result.request.headers["User-Agent"]).toBe("Mozilla");
});
it("strips ?token / ?email / ?password / ?secret / ?signature from URLs", () => {
const event = {
request: {
url: "https://app/api/x?token=abc&email=a@b.c&password=p&secret=z&signature=s&safe=1",
},
transaction: "/foo?accessToken=t",
} as Parameters<typeof beforeSendTransaction>[0];
const result = beforeSendTransaction(event, {}) as {
request: { url: string };
transaction: string;
};
const url = decodeURIComponent(result.request.url);
const txn = decodeURIComponent(result.transaction);
for (const key of ["token", "email", "password", "secret", "signature"]) {
expect(url).toContain(`${key}=[redacted]`);
}
expect(url).toContain("safe=1");
expect(txn).toContain("accessToken=[redacted]");
});
});

View File

@@ -14,9 +14,12 @@ vi.mock("@repo/navigation/di/bind-dev-seed", () => ({ bindDevSeedNavigation: vi.
vi.mock("@repo/media/di/bind-dev-seed", () => ({ bindDevSeedMedia: vi.fn() }));
vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => {
const actual = await importOriginal<typeof import("@repo/core-shared/instrumentation")>();
const mockedOtel = vi.fn(actual.bindOtelInstrumentation);
return {
...actual,
bindSentryInstrumentation: vi.fn(actual.bindSentryInstrumentation),
bindOtelInstrumentation: mockedOtel,
// Deprecated alias — points to same spy so existing assertions still work.
bindSentryInstrumentation: mockedOtel,
bindNoopInstrumentation: vi.fn(actual.bindNoopInstrumentation),
};
});
@@ -157,44 +160,48 @@ describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => {
vi.unstubAllEnvs();
});
// In the mock setup above, bindSentryInstrumentation is an alias that points
// to the same spy as bindOtelInstrumentation. Assertions against either name
// verify the same call, which also validates the deprecation alias is wired.
it("DSN absent → bindNoopInstrumentation regardless of NODE_ENV (R48)", async () => {
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "");
vi.stubEnv("NODE_ENV", "production");
const { bindAll } = await import("./bind-production");
const { bindNoopInstrumentation, bindSentryInstrumentation } = await import(
const { bindNoopInstrumentation, bindOtelInstrumentation } = await import(
"@repo/core-shared/instrumentation"
);
await bindAll();
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
expect(bindSentryInstrumentation).not.toHaveBeenCalled();
expect(bindOtelInstrumentation).not.toHaveBeenCalled();
});
it("DSN set → bindSentryInstrumentation regardless of NODE_ENV", async () => {
it("DSN set → bindOtelInstrumentation regardless of NODE_ENV", async () => {
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1");
vi.stubEnv("NODE_ENV", "development");
const { bindAll } = await import("./bind-production");
const { bindNoopInstrumentation, bindSentryInstrumentation } = await import(
const { bindNoopInstrumentation, bindOtelInstrumentation } = await import(
"@repo/core-shared/instrumentation"
);
await bindAll();
expect(bindSentryInstrumentation).toHaveBeenCalledOnce();
expect(bindOtelInstrumentation).toHaveBeenCalledOnce();
expect(bindNoopInstrumentation).not.toHaveBeenCalled();
});
it("Sentry instrumentation works alongside dev seed (USE_DEV_SEED=true)", async () => {
it("OTel instrumentation works alongside dev seed (USE_DEV_SEED=true)", async () => {
vi.stubEnv("USE_DEV_SEED", "true");
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1");
const { bindAll } = await import("./bind-production");
const { bindSentryInstrumentation } = await import("@repo/core-shared/instrumentation");
const { bindOtelInstrumentation } = await import("@repo/core-shared/instrumentation");
const { bindDevSeedBlog } = await import("@repo/blog/di/bind-dev-seed");
await bindAll();
expect(bindSentryInstrumentation).toHaveBeenCalledOnce();
expect(bindOtelInstrumentation).toHaveBeenCalledOnce();
expect(bindDevSeedBlog).toHaveBeenCalledOnce();
});

View File

@@ -6,7 +6,7 @@ import { getPayload } from "payload";
import config from "@repo/core-cms";
import {
bindNoopInstrumentation,
bindSentryInstrumentation,
bindOtelInstrumentation,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
@@ -45,7 +45,7 @@ function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
}
const dsn = process.env.WEB_NEXT_SENTRY_DSN;
const result = dsn
? bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" })
? bindOtelInstrumentation(sharedContainer, { dsn, app: "web-next" })
: bindNoopInstrumentation(sharedContainer);
resolvedTracer = result.tracer;
resolvedLogger = result.logger;

File diff suppressed because one or more lines are too long

View File

@@ -1,56 +0,0 @@
import { describe, it, expect } from "vitest";
import {
beforeSend,
beforeSendTransaction,
} from "@repo/core-shared/instrumentation/sentry/scrub";
describe("R38 — apps/web-tanstack PII scrubber", () => {
it("strips email/password/cookie/auth/IP from event payload", () => {
const event = {
extra: {
userEmail: "alice@example.com",
password: "p4$$w0rd",
ipAddress: "192.168.1.10",
note: "request from 10.0.0.1",
},
request: {
headers: {
Authorization: "Bearer secret",
"Set-Cookie": "session=abc",
"User-Agent": "Mozilla",
},
},
} as Parameters<typeof beforeSend>[0];
const result = beforeSend(event, {}) as {
extra: Record<string, string>;
request: { headers: Record<string, string> };
};
expect(result.extra["userEmail"]).toBe("[redacted]");
expect(result.extra["password"]).toBe("[redacted]");
expect(result.extra["ipAddress"]).toBe("[redacted]");
expect(result.extra["note"]).toContain("[redacted-ip]");
expect(result.request.headers["Authorization"]).toBe("[redacted]");
expect(result.request.headers["Set-Cookie"]).toBe("[redacted]");
expect(result.request.headers["User-Agent"]).toBe("Mozilla");
});
it("strips ?token / ?email / ?password / ?secret / ?signature from URLs", () => {
const event = {
request: {
url: "https://app/api/x?token=abc&email=a@b.c&password=p&secret=z&signature=s&safe=1",
},
transaction: "/foo?accessToken=t",
} as Parameters<typeof beforeSendTransaction>[0];
const result = beforeSendTransaction(event, {}) as {
request: { url: string };
transaction: string;
};
const url = decodeURIComponent(result.request.url);
const txn = decodeURIComponent(result.transaction);
for (const key of ["token", "email", "password", "secret", "signature"]) {
expect(url).toContain(`${key}=[redacted]`);
}
expect(url).toContain("safe=1");
expect(txn).toContain("accessToken=[redacted]");
});
});

View File

@@ -1,9 +1,12 @@
// apps/web-tanstack/src/instrumentation.ts
// Server-entry hook. Imported at the top of the server entry file.
import { initSentryServerNode } from "@repo/core-shared/instrumentation/sentry/init-server-node";
// Server-entry hook. Imported at the top of the server entry file before any
// request handler runs. Initializes the OTel SDK here so PII scrub processors
// are active from the very first request (C1 fix — closes the startup window
// where Sentry auto-instrumentation could send unscrubbed errors).
import { initOtelServerNode } from "@repo/core-shared/instrumentation/otel/init-server-node";
initSentryServerNode({
dsn: process.env["WEB_TANSTACK_SENTRY_DSN"],
app: "web-tanstack",
release: process.env["VITE_GIT_COMMIT_SHA"],
initOtelServerNode({
dsn: process.env["WEB_TANSTACK_SENTRY_DSN"] ?? "",
serviceName: "web-tanstack",
environment: process.env["NODE_ENV"] ?? "development",
});

View File

@@ -2378,12 +2378,13 @@ footer .colophon {
</div>
<h3 class="trace-h3">The trace tree (one tRPC request)</h3>
<pre class="trace-tree"><code>HTTP transaction (auto, @sentry/nextjs)
└── tRPC procedure span (auto, sentry trpc integration)
└── controller span (op="controller", composed at DI bind time)
└── use-case span (op="use-case", composed at DI bind time)
└── repository span (op="repository", inline per method)
└── Payload Local API call (auto, @sentry/node http)</code></pre>
<p class="trace-p"><strong>Substrate: OpenTelemetry SDK</strong> (ADR-017). Sentry is the exporter via <code>@sentry/opentelemetry</code>. Auto-instrumentations cover HTTP, undici, and pg; feature code emits via <code>ITracer</code> / <code>ILogger</code> interfaces only.</p>
<pre class="trace-tree"><code>HTTP transaction (auto, OTel HttpInstrumentation)
└── tRPC procedure span (auto, OTel + Sentry tRPC integration)
└── controller span (op="controller", composed at DI bind time via OtelTracer)
└── use-case span (op="use-case", composed at DI bind time via OtelTracer)
└── repository span (op="repository", inline per method via ITracer)
└── Payload Local API call (auto, OTel PgInstrumentation / UndiciInstrumentation)</code></pre>
<h3 class="trace-h3">Where instrumentation actually lives</h3>
<p class="trace-p">Two ways spans + captures get attached. <strong>Inline</strong> means the call appears in the layer's own body. <strong>Composed-in</strong> means a higher-order wrapper applied at DI bind time — the body stays vendor-clean.</p>
@@ -2502,16 +2503,16 @@ const wrappedCtrl = withSpan(
</table>
<h3 class="trace-h3">Double-report guard</h3>
<p class="trace-p">Each error gets a non-enumerable <code>__sentryReported</code> flag the first time it's captured. <code>withCapture</code>, <code>SentryLogger</code>, and <code>RecordingLogger</code> all check the flag and bail if it's set. So an error bubbling repo → use-case → controller surfaces in the logger <strong>exactly once</strong>, with the inner-most layer's tags. Helper lives in <code>core-shared/instrumentation/reported-flag.ts</code>.</p>
<p class="trace-p">Each error gets a non-enumerable <code>__sentryReported</code> flag the first time it's captured. <code>withCapture</code>, <code>OtelLogger</code>, and <code>RecordingLogger</code> all check the flag and bail if it's set. So an error bubbling repo → use-case → controller surfaces in the logger <strong>exactly once</strong>, with the inner-most layer's tags. Helper lives in <code>core-shared/instrumentation/reported-flag.ts</code>.</p>
<h3 class="trace-h3">PII rules (R31R38, non-negotiable)</h3>
<ul class="pii-rules">
<li><code>sendDefaultPii: false</code> — every <code>Sentry.init()</code>. CI grep gate.</li>
<li>Replay <strong>default-masks all text + inputs + media</strong>. Allowlist starts empty.</li>
<li><code>beforeSend</code> scrubber strips email / password / token / cookie / authorization / ipaddress keys (substring match).</li>
<li><code>beforeSendTransaction</code> scrubber strips PII query params from URLs.</li>
<li><code>setUser</code> accepts only <code>{ id }</code>. Stripping wrapper warns in dev when other keys passed.</li>
<li>IPv4/IPv6 in event payload string values redacted to <code>[redacted-ip]</code>.</li>
<li><strong>Server-side:</strong> <code>PiiScrubSpanProcessor</code> + <code>PiiScrubLogRecordProcessor</code> run FIRST in the OTel processor chain — attribute-key substring match strips email / password / token / cookie / authorization / ipaddress keys before the Sentry exporter sees the data (R32, R33, ADR-017 §7).</li>
<li><strong>Browser-side:</strong> <code>beforeSend</code> / <code>beforeSendTransaction</code> hooks in <code>init-client*.ts</code> strip the same PII keys (browser does not use the OTel pipeline).</li>
<li><code>setUser</code> accepts only <code>{ id }</code>. No email/username (R36).</li>
<li>IPv4/IPv6 redacted to <code>[redacted-ip]</code> in browser-side scrubbers.</li>
</ul>
</section>

View File

@@ -94,18 +94,21 @@ Three layers work in tandem:
The two enforcement layers are independent but complementary. ESLint is stricter on per-import context (e.g., file-specific exemptions via `// @boundaries-ignore`), while Turborepo catches transitive issues that lint-time checking misses. Run `pnpm lint` and `pnpm turbo boundaries` in CI to catch all violations.
## TRACER / LOGGER (Plan 10)
## TRACER / LOGGER / METRICS (ADR-014, ADR-017)
The instrumentation layer is **per-feature container** but **app-wide instance**: each feature container binds `INSTRUMENTATION_SYMBOLS.TRACER` and `INSTRUMENTATION_SYMBOLS.LOGGER` to the SAME instance, constructed once by the app's `bindAll()` dispatcher (Rule 0).
The instrumentation layer is **per-feature container** but **app-wide instance**: each feature container binds `INSTRUMENTATION_SYMBOLS.ITracer`, `INSTRUMENTATION_SYMBOLS.ILogger`, and `INSTRUMENTATION_SYMBOLS.IMetrics` to the SAME instances, constructed once by the app's `bindAll()` dispatcher (Rule 0).
**Substrate:** OpenTelemetry SDK. Sentry is the exporter via `@sentry/opentelemetry`. PII scrubbing runs at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before the Sentry exporter sees the data. Feature code is never coupled to the OTel SDK or Sentry SDK directly (ADR-017).
```
apps/web-next/src/server/bind-production.ts (bindAll)
├─ Rule 0: WEB_NEXT_SENTRY_DSN set?
│ yes → bindSentryInstrumentation(sharedContainer, { dsn, app: "web-next" })
│ yes → bindOtelInstrumentation(sharedContainer, { dsn, app: "web-next" })
│ → initOtelServerNode(dsn, ...) → OTel SDK + Sentry exporter + PII scrub processors
│ no → bindNoopInstrumentation(sharedContainer)
│ ↓
│ tracer + logger instances
│ tracer (OtelTracer) + logger (OtelLogger) + metrics (OtelMetrics) instances
│ ↓
├─ resolveEventsAndJobs* → IEventBus + IJobQueue (ADR-015)
│ production → PayloadJobsEventBus + PayloadJobQueue
@@ -115,9 +118,9 @@ apps/web-next/src/server/bind-production.ts (bindAll)
│ server.ts → SocketIORealtimeBroadcaster + RealtimeHandlerRegistry (passed in from server.ts)
│ page/test → InMemoryRealtimeBroadcaster + RealtimeHandlerRegistry (defaults)
│ ↓
├─ build ctx: BindProductionContext = { config, tracer, logger, bus, queue, realtime, realtimeRegistry }
├─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry }
│ Required: tracer, logger, config (production only)
│ Optional: bus, queue, realtime, realtimeRegistry (guard with ?. when used)
│ Optional: metrics, bus, queue, realtime, realtimeRegistry (guard with ?. when used)
│ ↓
├─ bindProductionBlog(ctx: BindProductionContext)
│ │
@@ -138,8 +141,9 @@ apps/web-next/src/server/bind-production.ts (bindAll)
| Field | Type | Required | Notes |
|---|---|---|---|
| `tracer` | `ITracer` | always | Resolved by Rule 0 (Sentry vs Noop) |
| `tracer` | `ITracer` | always | Resolved by Rule 0 (OTel+Sentry vs Noop) |
| `logger` | `ILogger` | always | Resolved by Rule 0 |
| `metrics` | `MetricsProtocol?` | optional | Resolved by Rule 0; per-feature adoption is opportunistic |
| `config` | `SanitizedConfig` | production only | Present in `BindProductionContext`, absent in `BindContext` |
| `bus` | `EventBusProtocol?` | optional | `IEventBus` at the aggregator; protocol surface at binders |
| `queue` | `IJobQueue?` | optional | Present when `core-shared/jobs` is wired |
@@ -150,6 +154,6 @@ Feature binders destructure `ctx` and use optional fields with `?.` or cast to t
**Why per-feature containers also get the binding:** lets internal DI-resolved code in a feature pull TRACER/LOGGER without going through the app dispatcher. In practice, only repository classes and feature-internal services would use this — controllers and use cases receive instrumentation via the bind-time wrapper.
**Why the shared container exists at all:** isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is on or off — they just receive an `ITracer` instance.
**Why the shared container exists at all:** isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is the exporter or not — they just receive an `ITracer` instance.
**Boundary rule:** feature packages MUST NOT import `@sentry/*` directly (R40, ESLint-enforced). The only paths that may import the SDK are `core-shared/instrumentation/sentry/**`, the `bind-sentry-instrumentation` files, the `no-sentry` test guards, and per-app `instrumentation*.{ts,mjs}` / `next.config.{mjs}` / `vite.config.{ts}` entries.
**Boundary rule:** feature packages MUST NOT import `@sentry/*` or `@opentelemetry/sdk-*` directly (R40 + R52, ESLint-enforced). The OTel bridge (`otel/sentry-bridge.ts`), browser init files (`sentry/init-client*.ts`), and app-level `instrumentation*.{ts,mjs}` / `next.config.{mjs}` / `vite.config.{ts}` entries are the only allowlisted paths.

View File

@@ -1170,28 +1170,32 @@ footer .colophon {
<div class="section-num">§ 08</div>
<div>
<h2 class="section-title">Instrumentation <em>symbols</em>.</h2>
<p class="section-blurb">Plan 10 added two new symbols to the per-feature container — <code>TRACER</code> and <code>LOGGER</code> — bound by a separate Rule 0 in <code>bindAll()</code> that's <strong>orthogonal</strong> to the repo binding mode. The DSN env var decides Sentry vs Noop; <code>USE_DEV_SEED</code> / <code>NODE_ENV</code> decide real vs mock repos.</p>
<p class="section-blurb">ADR-014 + ADR-017 added instrumentation symbols to the per-feature container — <code>ITracer</code>, <code>ILogger</code>, and <code>IMetrics</code> — bound by a separate Rule 0 in <code>bindAll()</code> that's <strong>orthogonal</strong> to the repo binding mode. The DSN env var decides OTel+Sentry vs Noop; <code>USE_DEV_SEED</code> / <code>NODE_ENV</code> decide real vs mock repos. <strong>Substrate: OpenTelemetry SDK.</strong> Sentry is the exporter via <code>@sentry/opentelemetry</code>.</p>
</div>
</div>
<div class="instrumentation-grid">
<div>
<h3 class="trace-h3"><code>INSTRUMENTATION_SYMBOLS.TRACER</code></h3>
<p class="trace-p">Bound by either <code>bindNoopInstrumentation</code> or <code>bindSentryInstrumentation</code> to <code>NoopTracer</code> or <code>SentryTracer</code>. Decided by Rule 0: DSN env present → Sentry; otherwise Noop.</p>
<h3 class="trace-h3"><code>INSTRUMENTATION_SYMBOLS.ITracer</code></h3>
<p class="trace-p">Bound by either <code>bindNoopInstrumentation</code> or <code>bindOtelInstrumentation</code> to <code>NoopTracer</code> or <code>OtelTracer</code>. Decided by Rule 0: DSN env present → OTel SDK + Sentry exporter; otherwise Noop. <code>OtelTracer</code> emits via <code>@opentelemetry/api</code>; spans flow to Sentry via <code>SentrySpanProcessor</code>.</p>
<h3 class="trace-h3"><code>INSTRUMENTATION_SYMBOLS.LOGGER</code></h3>
<p class="trace-p">Same rule, same lifecycle. <code>NoopLogger</code> in the absence of a DSN; <code>SentryLogger</code> when DSN is set. The Sentry adapter applies the <code>__sentryReported</code> double-report guard internally — call sites don't manage the flag.</p>
<h3 class="trace-h3"><code>INSTRUMENTATION_SYMBOLS.ILogger</code></h3>
<p class="trace-p">Same rule, same lifecycle. <code>NoopLogger</code> in the absence of a DSN; <code>OtelLogger</code> when DSN is set. <code>OtelLogger</code> emits log records via <code>@opentelemetry/api-logs</code>; errors flow to Sentry via <code>SentryLogRecordProcessor</code>. The <code>__sentryReported</code> double-report guard is applied before emitting.</p>
<h3 class="trace-h3"><code>INSTRUMENTATION_SYMBOLS.IMetrics</code></h3>
<p class="trace-p">Bound to <code>NoopMetrics</code> or <code>OtelMetrics</code>. <code>OtelMetrics</code> uses the OTel metrics API (<code>counter</code>, <code>histogram</code>, <code>gauge</code>). Per-feature adoption is opportunistic — no feature call sites required.</p>
</div>
<div>
<h3 class="trace-h3">Wiring path</h3>
<pre class="trace-tree"><code>bindAll()
└─ resolveInstrumentation() ← Rule 0 (DSN check)
└─ Noop or Sentry binders ← bind to sharedContainer
└─ resolveInstrumentation() ← Rule 0 (DSN check)
└─ bindOtelInstrumentation() ← OTel SDK init + Sentry exporter + PII scrub processors
OR bindNoopInstrumentation() ← all Noop
└─ resolveEventsAndJobs*() ← ADR-015 (env-driven bus + queue)
└─ Payload-backed in prod, in-memory in dev-seed
└─ build ctx: BindProductionContext = { config, tracer, logger, bus, queue, realtime, realtimeRegistry }
└─ required: tracer, logger, config | optional: bus, queue, realtime, realtimeRegistry
└─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry }
└─ required: tracer, logger, config | optional: metrics, bus, queue, realtime, realtimeRegistry
└─ bindProductionX(ctx) ← single ctx object passed to each feature binder
└─ feature container also binds TRACER + LOGGER
└─ withSpan(withCapture(...)) at every use case + controller

View File

@@ -685,15 +685,17 @@ Invoke the `superpowers:writing-plans` skill to produce a detailed, executable i
---
## 16. Instrumentation & error capture (Plan 10)
## 16. Instrumentation & error capture (ADR-014, ADR-017)
**Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31R55).
**Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31R55 interfaces); `docs/decisions/adr-017-opentelemetry-migration.md` (OTel substrate, supersedes ADR-014 impl section).
**Substrate:** OpenTelemetry SDK. Sentry is the exporter via `@sentry/opentelemetry`. PII scrubbing happens at the OTel processor layer before the Sentry exporter. Feature code depends only on `ITracer`, `ILogger`, `IMetrics` interfaces — no Sentry or OTel SDK imports.
**File additions per feature:**
- `infrastructure/repositories/<entity>.repository.ts` — constructor takes `(config, tracer, logger)` with Noop defaults; every public method's body is wrapped in `tracer.startSpan(...)` and any `catch` block calls `logger.captureException(err, { tags: { feature, repo, method } })` before re-throwing.
- `infrastructure/repositories/<entity>.repository.mock.ts` — same constructor/wrapping shape (no catch — mocks don't originate infra errors).
- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, bus, queue, realtime, realtimeRegistry }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`bus`, `queue`, `realtime`, `realtimeRegistry`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them.
- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`metrics`, `bus`, `queue`, `realtime`, `realtimeRegistry`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them.
- `di/bind-dev-seed.ts` — signature `(ctx: BindContext)` (no `config`). Same wrapping as bind-production but with the populated mock.
**Required exports (per feature root):** unchanged.

View File

@@ -1,6 +1,7 @@
# ADR-014 — Instrumentation & Sentry Logging
**Status:** Accepted
**Status (revised):** Superseded by ADR-017 for the implementation layer. The interface decisions (R31R51) remain authoritative.
**Date:** 2026-05-06
**Spec:** docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md
**Plan:** docs/superpowers/plans/2026-05-06-plan-10-instrumentation-sentry.md

View File

@@ -0,0 +1,53 @@
# ADR-017 — OpenTelemetry Migration
**Status:** Accepted
**Date:** 2026-05-11
**Spec:** docs/superpowers/specs/2026-05-11-opentelemetry-migration-design.md
**Plan:** docs/superpowers/plans/2026-05-11-opentelemetry-migration.md
**Supersedes (impl section):** ADR-014
## Context
ADR-014 established vendor-neutral `ITracer` + `ILogger` interfaces with Sentry as the active backend. The interface decisions (R31R51) have held up; what coupled to a vendor was the **substrate**: `SentryTracer` and `SentryLogger` called Sentry SDK methods directly. Swapping vendors required rewriting every `*Tracer`/`*Logger` pair.
This ADR migrates the substrate to OpenTelemetry: code emits OTel spans, logs, and metrics; exporters route to one or more backends. Sentry is wired as the (initially only) exporter via `@sentry/opentelemetry`. Swapping vendors becomes an exporter swap.
## Decision
1. **OTel SDK as substrate.** Server-side `ITracer` and `ILogger` impls use `@opentelemetry/api` and `@opentelemetry/api-logs` respectively. New `IMetrics` signal added via OTel metrics API.
2. **Sentry-as-exporter.** `@sentry/opentelemetry` provides `SentrySpanProcessor` + `SentryLogRecordProcessor`. They consume OTel signals and forward to Sentry. Sentry's UI experience is preserved (minus some browser-side richness, addressed below).
3. **Server-only scope.** Browser keeps Sentry SDK directly. Replay + session-error correlation stay native. Future spec extends OTel to browser when warranted.
4. **Pure OTel Logs API for the logger.** `OtelLogger` emits via `@opentelemetry/api-logs`. Trade-off: slightly degraded Sentry-native error UX (stack normalization, breadcrumb buffer) in exchange for swap-by-exporter vendor neutrality.
5. **Breadcrumbs → span events.** `ILogger.addBreadcrumb` attaches to the active OTel span as an event. Native OTel pattern.
6. **`setUser` per-span.** Sets `user.id` as a span attribute on the active span. R36 preserved (id only; no email/username).
7. **PII scrubbing migrated.** From Sentry's `beforeSend`/`beforeSendTransaction` hooks to OTel `SpanProcessor` + `LogRecordProcessor` impls (`PiiScrubSpanProcessor`, `PiiScrubLogRecordProcessor`). Processors run BEFORE the Sentry exporter, so PII is stripped at the OTel layer regardless of downstream exporter. Browser init files (`init-client.ts`, `init-client-react.ts`) retain `beforeSend`/`beforeSendTransaction` hooks because they do not use the OTel pipeline.
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 for one release cycle.
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.
11. **Auto-instrumentations enabled.** HTTP (`@opentelemetry/instrumentation-http`), undici (`instrumentation-undici`), pg (`instrumentation-pg`) registered in `initOtelServerNode`. HTTP instrumentation strips query strings from `http.url.path` attribute and ignores `/_health` and `/_otel-export` paths. PgInstrumentation has `enhancedDatabaseReporting: false` to avoid SQL statement capture (R32 — SQL often contains PII in WHERE clauses).
12. **`no-sentry.ts``no-instrumentation.ts` in `core-testing`.** Renamed with backward-compat alias for one release. Mocks both Sentry SDK and OTel SDK modules to prevent real init in vitest runs.
## 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.
- **Put PII scrub in Sentry exporter config.** Rejected — `beforeSend` hooks run inside the Sentry SDK after OTel signals are converted; the OTel processor layer is earlier and vendor-agnostic. Scrubbing at the processor layer means any future exporter added alongside Sentry also sees clean data.
## 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.
- PII scrubbing is vendor-neutral — applies before any exporter sees the data.
**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 (~12 new packages in `core-shared`).
## Relationship to ADR-014
ADR-014's interface decisions (R31R51) remain authoritative. This ADR supersedes only the implementation section (Sentry SDK direct calls → OTel SDK). ADR-014 keeps a "Status: Superseded for impl by ADR-017" header.

View File

@@ -85,15 +85,36 @@ export default [
},
},
// R40 allowlist — the only paths permitted to import @sentry/*.
// After the OTel migration (ADR-017): server-side Sentry SDK usage is limited to
// the OTel bridge + browser/client init files. The @sentry/* ESLint restriction
// stays; the allowlist is narrowed from the original "**/instrumentation/sentry/**".
// Patterns are double-star prefixed so they match whether eslint runs from
// the repo root or from inside a sub-package.
{
files: [
"**/instrumentation/sentry/**",
"**/instrumentation/di/bind-sentry-instrumentation.{ts,js}",
"**/instrumentation/di/bind-sentry-instrumentation.test.{ts,js}",
// OTel bridge — the only server-side file that may import @sentry/opentelemetry
"**/instrumentation/otel/sentry-bridge.{ts,js}",
"**/instrumentation/otel/sentry-bridge.test.{ts,js}",
// OTel DI binder — may import @sentry/opentelemetry via sentry-bridge
"**/instrumentation/di/bind-otel-instrumentation.{ts,js}",
"**/instrumentation/di/bind-otel-instrumentation.test.{ts,js}",
// Browser-side Sentry init helpers (server-only migration — browser keeps Sentry SDK directly)
"**/instrumentation/sentry/init-client.{ts,js}",
"**/instrumentation/sentry/init-client.test.{ts,js}",
"**/instrumentation/sentry/init-client-react.{ts,js}",
"**/instrumentation/sentry/init-client-react.test.{ts,js}",
// Server-side Sentry SDK init still used by apps (calls Sentry.init with DSN)
"**/instrumentation/sentry/init-server.{ts,js}",
"**/instrumentation/sentry/init-server.test.{ts,js}",
"**/instrumentation/sentry/init-server-node.{ts,js}",
"**/instrumentation/sentry/init-server-node.test.{ts,js}",
// Test guard — mocks Sentry + OTel SDKs to prevent real init in test processes
"**/setup/no-instrumentation.{ts,js}",
"**/setup/no-instrumentation.test.{ts,js}",
// Legacy alias for one release cycle
"**/setup/no-sentry.{ts,js}",
"**/setup/no-sentry.test.{ts,js}",
// App-level instrumentation entry points and build config
"**/instrumentation.{ts,js,mjs}",
"**/instrumentation-client.{ts,js,mjs}",
"**/next.config.{mjs,ts,js}",
@@ -104,6 +125,25 @@ export default [
"no-restricted-imports": "off",
},
},
// R52 — OTel SDK packages (@opentelemetry/sdk-*, @opentelemetry/resources,
// @opentelemetry/semantic-conventions, @opentelemetry/instrumentation-*,
// @sentry/opentelemetry) are restricted to core-shared/instrumentation/otel/
// and app-level init paths.
// The vendor-neutral API packages (@opentelemetry/api, @opentelemetry/api-logs)
// are unrestricted within core-shared/instrumentation/ — features use them for
// advanced tracing without coupling to the SDK.
{
files: [
"**/instrumentation/otel/**/*.{ts,tsx,mjs,cjs,js}",
// App-level init and build config also allowed to import OTel SDK packages
"**/instrumentation.{ts,js,mjs}",
"**/next.config.{mjs,ts,js}",
"**/vite.config.{ts,mjs,js}",
],
rules: {
"no-restricted-imports": "off",
},
},
// E1 — Event handlers must not be re-exported. Wire them only inside the
// consumer feature's bind-production / bind-dev-seed (spec § 2.2 Rule E1).
// J — Direct `payload.jobs.*` access is forbidden outside the integration

View File

@@ -38,13 +38,15 @@ Covered areas:
## src/instrumentation/
**Two interfaces:** `ITracer` (in `tracer.interface.ts`) and `ILogger` (in `logger.interface.ts`).
**Substrate:** OpenTelemetry SDK (ADR-017). Sentry is the exporter via `@sentry/opentelemetry`. Feature packages depend only on the interfaces below — no Sentry or OTel SDK imports.
**Three interfaces:** `ITracer` (`tracer.interface.ts`), `ILogger` (`logger.interface.ts`), `IMetrics` (`metrics.interface.ts`).
**Three implementation pairs:**
- `NoopTracer` / `NoopLogger` — pass-through. Default everywhere.
- `SentryTracer` / `SentryLogger` — adapters over `@sentry/nextjs`. Live in `sentry/` subfolder. **The `sentry/` subfolder is the only path in `packages/` permitted to import `@sentry/*`** (R40), with the additional exception of `instrumentation/di/bind-sentry-instrumentation.{ts,test.ts}`.
- `RecordingTracer` / `RecordingLogger` — in `@repo/core-testing/instrumentation`, not here.
- `NoopTracer` / `NoopLogger` / `NoopMetrics` — pass-through. Default everywhere.
- `OtelTracer` / `OtelLogger` / `OtelMetrics` — emit via OTel API (`@opentelemetry/api`, `@opentelemetry/api-logs`). Live in `otel/` subfolder. **The `otel/` subfolder is the only path in `packages/` permitted to import `@opentelemetry/sdk-*` or `@sentry/opentelemetry`** (R52).
- `RecordingTracer` / `RecordingLogger` / `RecordingMetrics` — in `@repo/core-testing/instrumentation`, not here.
**`with-span.ts` + `with-capture.ts`:** two higher-order helpers used at DI binding time to wrap use case + controller factory results. The binders apply them as a sandwich — `withSpan` outermost, `withCapture` between span and factory:
@@ -62,29 +64,33 @@ const wrapped = withSpan(
`withSpan` is pure delegation to `tracer.startSpan`. `withCapture` catches thrown errors, calls `logger.captureException(err, { tags })`, marks the `__sentryReported` flag, and re-throws — but bails if the flag is already set so the inner-most layer wins.
**`reported-flag.ts`:** small module exporting `markReported(err)` and `isReported(err)`. Used by `withCapture` and `SentryLogger`. `RecordingLogger` carries an inlined copy (tooling → core import is disallowed by the boundary rule).
**`reported-flag.ts`:** small module exporting `markReported(err)` and `isReported(err)`. Used by `withCapture` and `OtelLogger`. `RecordingLogger` carries an inlined copy (tooling → core import is disallowed by the boundary rule).
**Symbols:** `INSTRUMENTATION_SYMBOLS.TRACER`, `INSTRUMENTATION_SYMBOLS.LOGGER` (both `Symbol.for(...)` so cross-realm equality holds).
**Symbols:** `INSTRUMENTATION_SYMBOLS.ITracer`, `INSTRUMENTATION_SYMBOLS.ILogger`, `INSTRUMENTATION_SYMBOLS.IMetrics` (all `Symbol.for(...)` so cross-realm equality holds).
**`sentry/scrub.ts`:** PII scrubbers used by every `Sentry.init()` call across the monorepo. Substring-based key matching catches derived names (`userEmail`, `accessToken`, `apiKey`, `ipAddress`). IPv4/IPv6 are also redacted from string values via the `[redacted-ip]` token.
**`otel/pii-fields.ts`:** vendor-neutral PII substring list (`PII_KEY_SUBSTRINGS`, `PII_QUERY_PARAM_SUBSTRINGS`). Used by `otel/pii-scrub-processor.ts` (server) and imported by `sentry/init-client*.ts` (browser).
**`sentry/init-server.ts` + `init-client.ts`:** centralized init helpers (Next.js flavor) that hard-code R31 (`sendDefaultPii: false`), R32/R33 (scrubbers), R34/R35 (replay mask flags), R37 (sample-rate defaults). Apps call these from `instrumentation.ts` / `instrumentation-client.ts`.
**`otel/pii-scrub-processor.ts`:** `PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`. Registered FIRST in the OTel processor chain so all downstream exporters (Sentry) see scrubbed data. Replaces Sentry's `beforeSend`/`beforeSendTransaction` hooks on the server side (ADR-017 §7).
**`sentry/init-server-node.ts` + `init-client-react.ts`:** Vite/non-Next variants used by `apps/web-tanstack`. Same R31/R32/R33/R34/R35/R37 posture; uses `@sentry/node` + `@sentry/react` instead of `@sentry/nextjs`.
**`sentry/init-server.ts` + `sentry/init-client.ts`:** centralized init helpers (Next.js flavor). `init-server.ts` calls `Sentry.init` with `sendDefaultPii: false` (R31) — no `beforeSend` hook (PII scrubbed at OTel layer). `init-client.ts` retains `beforeSend`/`beforeSendTransaction` because browser does not use the OTel pipeline.
**`di/bind-noop-instrumentation.ts` + `bind-sentry-instrumentation.ts`:** bind TRACER + LOGGER symbols to a Container. Returns the resolved instances so callers can use them without container lookup.
**`sentry/init-server-node.ts` + `sentry/init-client-react.ts`:** Vite/non-Next variants used by `apps/web-tanstack`. Same posture as their Next.js counterparts.
**`di/bind-noop-instrumentation.ts` + `bind-otel-instrumentation.ts`:** bind ITracer + ILogger + IMetrics symbols to a Container. Returns the resolved instances so callers can use them without container lookup. `bindSentryInstrumentation` kept as a deprecated alias for one release.
**Subpath exports** (`package.json#exports`):
- `./instrumentation` — barrel (interfaces + Noops + withSpan + withCapture + reported-flag helpers + symbols + binders + node/react init helpers)
- `./instrumentation/sentry/init-server` — Next.js server init helper
- `./instrumentation/sentry/init-client` — Next.js client init helper
- `./instrumentation` — barrel (interfaces + Noops + OtelTracer/OtelLogger + withSpan + withCapture + reported-flag + symbols + binders)
- `./instrumentation/otel` — OTel init helper + resource builder barrel
- `./instrumentation/otel/init-server-node``initOtelServerNode` (app bootstrap, server-side OTel SDK)
- `./instrumentation/sentry/init-server` — Next.js server Sentry.init helper
- `./instrumentation/sentry/init-client` — Next.js browser Sentry.init helper
- `./instrumentation/sentry/init-server-node``@sentry/node` server init (TanStack Start)
- `./instrumentation/sentry/init-client-react``@sentry/react` client init (TanStack Start)
- `./instrumentation/sentry/scrub``beforeSend` / `beforeSendTransaction` (used by per-app PII test)
- `./instrumentation/sentry/init-client-react``@sentry/react` browser init (TanStack Start)
**Boundaries:**
- `core-shared/instrumentation/sentry/**` MAY import from `@sentry/*`.
- `core-shared/instrumentation/otel/**` MAY import from `@opentelemetry/sdk-*` and `@sentry/opentelemetry`.
- `core-shared/instrumentation/sentry/**` MAY import from `@sentry/*` (browser init files).
- Everything else in `packages/core-shared/src/` MUST NOT.
- The eslint rule in `core-eslint/base.js` enforces the broader monorepo boundary (R40).
- ESLint rules R40 + R52 in `core-eslint/base.js` enforce the broader monorepo boundary.

View File

@@ -14,11 +14,10 @@
"./trpc/context": "./src/trpc/context.ts",
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
"./instrumentation": "./src/instrumentation/index.ts",
"./instrumentation/sentry/init-server": "./src/instrumentation/sentry/init-server.ts",
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
"./instrumentation/otel/init-server-node": "./src/instrumentation/otel/init-server-node.ts",
"./instrumentation/sentry/init-client": "./src/instrumentation/sentry/init-client.ts",
"./instrumentation/sentry/init-server-node": "./src/instrumentation/sentry/init-server-node.ts",
"./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts",
"./instrumentation/sentry/scrub": "./src/instrumentation/sentry/scrub.ts"
"./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts"
},
"scripts": {
"build": "tsc --noEmit",
@@ -27,7 +26,20 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.55.0",
"@opentelemetry/instrumentation": "^0.55.0",
"@opentelemetry/instrumentation-http": "^0.55.0",
"@opentelemetry/instrumentation-pg": "^0.50.0",
"@opentelemetry/instrumentation-undici": "^0.10.0",
"@opentelemetry/resources": "^1.27.0",
"@opentelemetry/sdk-logs": "^0.55.0",
"@opentelemetry/sdk-metrics": "^1.27.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/sdk-trace-base": "^1.27.0",
"@opentelemetry/semantic-conventions": "^1.27.0",
"@sentry/nextjs": "^10.51.0",
"@sentry/opentelemetry": "^10.51.0",
"@trpc/server": "^11.0.0",
"payload": "^3.14.0",
"superjson": "^2.2.1",
@@ -42,6 +54,7 @@
"@sentry/react": { "optional": true }
},
"devDependencies": {
"@opentelemetry/context-async-hooks": "^1.28.0",
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",

View File

@@ -5,6 +5,7 @@ import type {
EventBusProtocol,
RealtimeBroadcasterProtocol,
RealtimeRegistryProtocol,
MetricsProtocol,
} from "./bind-protocols";
/** Always-present fields. Feature binders rely on these unconditionally. */
@@ -15,21 +16,26 @@ type BindContextBase = {
/**
* Optional cross-cutting deps. Generics let the app aggregator narrow the
* shape to full interfaces (`IEventBus`, `IRealtimeBroadcaster`, etc.); feature
* binders see only the protocol surface, which is enough for the methods they
* call. When an optional core package is absent the corresponding generic
* defaults to its protocol type, and `ctx.bus` / `ctx.realtime` are undefined
* at runtime.
* shape to full interfaces (`IEventBus`, `IRealtimeBroadcaster`, `IMetrics`,
* etc.); feature binders see only the protocol surface, which is enough for
* the methods they call. When an optional core package is absent the
* corresponding generic defaults to its protocol type, and `ctx.bus` /
* `ctx.realtime` / `ctx.metrics` are undefined at runtime.
*
* The 4th generic `Metrics` defaults to `MetricsProtocol` so existing call
* sites that pass 3 explicit args remain backward-compatible.
*/
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;
};
/** Production binders also receive the resolved Payload config. */
@@ -37,6 +43,7 @@ export type BindProductionContext<
Bus extends EventBusProtocol = EventBusProtocol,
Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol,
RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol,
> = BindContext<Bus, Realtime, RealtimeReg> & {
Metrics extends MetricsProtocol = MetricsProtocol,
> = BindContext<Bus, Realtime, RealtimeReg, Metrics> & {
config: SanitizedConfig;
};

View File

@@ -1,13 +1,13 @@
/**
* Minimal protocol surfaces used by feature binders to interact with optional
* cross-cutting infrastructure (event bus, realtime broadcaster, realtime
* handler registry). Lives in `core-shared` so `BindContext` can reference
* these unconditionally — features depend on `core-shared`, never on the
* optional packages directly.
* handler registry, metrics). Lives in `core-shared` so `BindContext` can
* reference these unconditionally — features depend on `core-shared`, never
* on the optional packages directly.
*
* The optional packages' full interfaces (`IEventBus`, `IRealtimeBroadcaster`,
* `IRealtimeHandlerRegistry`) `extends` these — typechecks fail if a refactor
* narrows the protocol surface in a way the full interface would lose.
* `IRealtimeHandlerRegistry`, `IMetrics`) `extends` these — typechecks fail if
* a refactor narrows the protocol surface in a way the full interface would lose.
*/
export type EventBusProtocol = {
@@ -31,3 +31,26 @@ export type RealtimeRegistryProtocol = {
registerChannel(descriptor: unknown): void;
listChannels(): unknown[];
};
/**
* Minimal metrics protocol surface. `IMetrics` in `core-shared/instrumentation`
* extends this — typechecks fail if `IMetrics` is narrowed below this surface.
* Feature binders that receive `ctx.metrics` see only this protocol type.
*/
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;
};

View File

@@ -2,22 +2,29 @@
import type { Container } from "inversify";
import { NoopTracer } from "../noop-tracer";
import { NoopLogger } from "../noop-logger";
import { NoopMetrics } from "../noop-metrics";
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
import type { ITracer, ILogger } from "../index";
import type { ITracer, ILogger, IMetrics } from "../index";
export function bindNoopInstrumentation(container: Container): {
tracer: ITracer;
logger: ILogger;
metrics: IMetrics;
} {
const tracer = new NoopTracer();
const logger = new NoopLogger();
const metrics = new NoopMetrics();
if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.METRICS)) {
container.unbind(INSTRUMENTATION_SYMBOLS.METRICS);
}
container.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
container.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
return { tracer, logger };
container.bind<IMetrics>(INSTRUMENTATION_SYMBOLS.METRICS).toConstantValue(metrics);
return { tracer, logger, metrics };
}

View File

@@ -0,0 +1,55 @@
// packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.test.ts
import "reflect-metadata";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@sentry/nextjs", () => ({
init: vi.fn(),
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
}));
import { Container } from "inversify";
import { bindOtelInstrumentation } from "@/instrumentation/di/bind-otel-instrumentation";
import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols";
import { OtelTracer } from "@/instrumentation/otel/otel-tracer";
import { OtelLogger } from "@/instrumentation/otel/otel-logger";
// NOTE: initOtelServerNode is intentionally NOT called by bindOtelInstrumentation.
// The SDK is initialized by each app's instrumentation.ts register() hook so PII
// scrub processors are active before the first request (C1 fix). There is therefore
// no initOtelServerNode mock or call-count assertion here.
describe("bindOtelInstrumentation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("binds OtelTracer + OtelLogger to the container", () => {
const c = new Container();
bindOtelInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" });
expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBeInstanceOf(OtelTracer);
expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBeInstanceOf(OtelLogger);
});
it("returns the tracer + logger instances", () => {
const c = new Container();
const { tracer, logger } = bindOtelInstrumentation(c, {
dsn: "https://x@y/1",
app: "web-next",
});
expect(tracer).toBeInstanceOf(OtelTracer);
expect(logger).toBeInstanceOf(OtelLogger);
});
it("rebinds when called a second time (idempotent container state)", () => {
const c = new Container();
bindOtelInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" });
// Second call should not throw even if TRACER is already bound.
expect(() =>
bindOtelInstrumentation(c, { dsn: "https://x@y/2", app: "cms" }),
).not.toThrow();
expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBeInstanceOf(OtelTracer);
});
});

View File

@@ -0,0 +1,48 @@
// packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts
import type { Container } from "inversify";
import { OtelTracer } from "../otel/otel-tracer";
import { OtelLogger } from "../otel/otel-logger";
import { OtelMetrics } from "../otel/otel-metrics";
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
import type { ITracer, ILogger, IMetrics } from "../index";
export type BindOtelOpts = {
dsn: string;
app: "web-next" | "cms" | "web-tanstack";
release?: string;
};
/**
* Binds OtelTracer, OtelLogger, and OtelMetrics to the DI container.
*
* NOTE: The OTel NodeSDK is NOT initialized here. It is initialized by each
* app's instrumentation.ts `register()` hook (Next.js convention / server-entry
* hook for TanStack) so that PII scrub processors are active before the very
* first request handler runs — before bindAll() fires. Calling initOtelServerNode
* here as well would create a second SDK init path and reintroduce the startup
* window vulnerability (C1 fix).
*/
export function bindOtelInstrumentation(
container: Container,
// opts is accepted for API compatibility with call sites that still pass dsn + app.
// The SDK is initialized by instrumentation.ts register() — no fields are used here.
_opts: BindOtelOpts,
): { tracer: ITracer; logger: ILogger; metrics: IMetrics } {
const tracer = new OtelTracer();
const logger = new OtelLogger();
const metrics = new OtelMetrics();
if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.METRICS)) {
container.unbind(INSTRUMENTATION_SYMBOLS.METRICS);
}
container.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
container.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
container.bind<IMetrics>(INSTRUMENTATION_SYMBOLS.METRICS).toConstantValue(metrics);
return { tracer, logger, metrics };
}

View File

@@ -1,53 +0,0 @@
// packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.test.ts
import "reflect-metadata";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@sentry/nextjs", () => ({
init: vi.fn(),
startSpan: vi.fn((_opts: unknown, fn: (span: unknown) => unknown) =>
fn({ setAttribute: vi.fn(), setStatus: vi.fn() }),
),
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
replayIntegration: vi.fn(() => ({ name: "Replay" })),
}));
import * as Sentry from "@sentry/nextjs";
import { Container } from "inversify";
import { bindSentryInstrumentation } from "@/instrumentation/di/bind-sentry-instrumentation";
import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols";
import { SentryTracer } from "@/instrumentation/sentry/sentry-tracer";
import { SentryLogger } from "@/instrumentation/sentry/sentry-logger";
describe("bindSentryInstrumentation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls Sentry.init via initSentryServer", () => {
const c = new Container();
bindSentryInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" });
expect(Sentry.init).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((Sentry.init as any).mock.calls[0][0].dsn).toBe("https://x@y/1");
});
it("binds SentryTracer + SentryLogger to the container", () => {
const c = new Container();
bindSentryInstrumentation(c, { dsn: "https://x@y/1", app: "web-next" });
expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBeInstanceOf(SentryTracer);
expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBeInstanceOf(SentryLogger);
});
it("returns the tracer + logger instances", () => {
const c = new Container();
const { tracer, logger } = bindSentryInstrumentation(c, {
dsn: "https://x@y/1",
app: "web-next",
});
expect(tracer).toBeInstanceOf(SentryTracer);
expect(logger).toBeInstanceOf(SentryLogger);
});
});

View File

@@ -1,29 +0,0 @@
// packages/core-shared/src/instrumentation/di/bind-sentry-instrumentation.ts
import type { Container } from "inversify";
import { SentryTracer } from "../sentry/sentry-tracer";
import { SentryLogger } from "../sentry/sentry-logger";
import { initSentryServer, type InitServerOpts } from "../sentry/init-server";
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
import type { ITracer, ILogger } from "../index";
export type BindSentryOpts = InitServerOpts;
export function bindSentryInstrumentation(
container: Container,
opts: BindSentryOpts,
): { tracer: ITracer; logger: ILogger } {
initSentryServer(opts);
const tracer = new SentryTracer();
const logger = new SentryLogger();
if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
}
if (container.isBound(INSTRUMENTATION_SYMBOLS.LOGGER)) {
container.unbind(INSTRUMENTATION_SYMBOLS.LOGGER);
}
container.bind<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER).toConstantValue(tracer);
container.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER).toConstantValue(logger);
return { tracer, logger };
}

View File

@@ -9,16 +9,22 @@ export type {
Breadcrumb,
CaptureContext,
} from "./logger.interface";
export type { IMetrics, MetricAttributeValue } from "./metrics.interface";
export { NoopTracer } from "./noop-tracer";
export { NoopLogger } from "./noop-logger";
export { NoopMetrics } from "./noop-metrics";
export { withSpan } from "./with-span";
export { withCapture } from "./with-capture";
export { isReported, markReported } from "./reported-flag";
export { INSTRUMENTATION_SYMBOLS } from "./symbols";
export { bindNoopInstrumentation } from "./di/bind-noop-instrumentation";
export {
bindSentryInstrumentation,
type BindSentryOpts,
} from "./di/bind-sentry-instrumentation";
export { initSentryServerNode } from "./sentry/init-server-node";
bindOtelInstrumentation,
type BindOtelOpts,
} from "./di/bind-otel-instrumentation";
// Deprecated alias for one release cycle — callers should migrate to bindOtelInstrumentation.
export { bindOtelInstrumentation as bindSentryInstrumentation } from "./di/bind-otel-instrumentation";
export type { BindOtelOpts as BindSentryOpts } from "./di/bind-otel-instrumentation";
export { initSentryClientReact } from "./sentry/init-client-react";

View File

@@ -0,0 +1,42 @@
import type { MetricsProtocol } from "../di/bind-protocols";
export type MetricAttributeValue = string | number | boolean;
/**
* Vendor-neutral metrics signal interface. Mirrors the pattern of ITracer / ILogger.
* Three impls: NoopMetrics (noop), OtelMetrics (OTel API), RecordingMetrics (core-testing).
*
* Extends MetricsProtocol from `core-shared/di/bind-protocols` so the type
* system enforces structural compatibility — narrowing IMetrics below the
* protocol surface causes a typecheck error.
*
* gauge() limitation: uses UpDownCounter under the hood, which accumulates deltas.
* True "set to absolute value" semantics require ObservableGauge with a callback —
* deferred to a v2 interface when the first true-gauge use case lands.
*/
export interface IMetrics extends MetricsProtocol {
/** 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. UpDownCounter under the hood — true gauge semantics
* (set to absolute value) require ObservableGauge with an async callback;
* that is deferred to a future v2 spec when the first true-gauge use case arrives.
*/
gauge(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void;
}

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { NoopMetrics } from "./noop-metrics";
describe("NoopMetrics", () => {
it("counter() returns undefined without throwing", () => {
const metrics = new NoopMetrics();
expect(() => metrics.counter("my.counter")).not.toThrow();
expect(() => metrics.counter("my.counter", 5)).not.toThrow();
expect(() =>
metrics.counter("my.counter", 1, { feature: "auth", success: true }),
).not.toThrow();
});
it("histogram() returns undefined without throwing", () => {
const metrics = new NoopMetrics();
expect(() => metrics.histogram("my.latency", 42)).not.toThrow();
expect(() =>
metrics.histogram("my.latency", 100, { route: "/api/me" }),
).not.toThrow();
});
it("gauge() returns undefined without throwing", () => {
const metrics = new NoopMetrics();
expect(() => metrics.gauge("queue.depth", 7)).not.toThrow();
expect(() =>
metrics.gauge("queue.depth", 3, { queue: "emails" }),
).not.toThrow();
});
});

View File

@@ -0,0 +1,21 @@
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 {}
}

View File

@@ -0,0 +1,2 @@
export { initOtelServerNode, type InitOtelServerNodeOpts } from "./init-server-node";
export { buildResource, type BuildResourceOpts } from "./resource";

View File

@@ -0,0 +1,23 @@
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();
});
});

View File

@@ -0,0 +1,95 @@
import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
import { buildResource } from "./resource";
import { createSentryOtelBridge } from "./sentry-bridge";
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
const { BatchSpanProcessor } = tracing;
export type InitOtelServerNodeOpts = {
/** Sentry DSN. When empty, OTel SDK boots without the Sentry exporter. */
dsn: string;
serviceName: string;
serviceVersion?: string;
environment: string;
release?: string;
namespace?: string;
};
/**
* Initializes the OpenTelemetry NodeSDK for a server-side app.
* - Configures Resource attributes per OTel semantic conventions.
* - Registers PII scrub processors FIRST so all downstream exporters see clean data.
* - Registers Sentry span processor (via createSentryOtelBridge) when DSN is set.
* - Registers Sentry log record processor when DSN is set.
* - Registers an in-process MeterProvider (no exporter — Sentry metrics not yet
* wired; a future phase or vendor-specific exporter can add a MetricReader).
*
* 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 });
// PiiScrubSpanProcessor runs FIRST so the Sentry exporter never sees raw PII.
const spanProcessors = bridge.spanProcessor
? [
new PiiScrubSpanProcessor(),
// `as never` works around a TypeScript version conflict: `core-shared`'s direct
// dep on `@opentelemetry/sdk-trace-base@1.30.1` has subtly incompatible types
// vs the 1.28.0 bundled by `sdk-node@0.55.0`. The runtime objects are compatible;
// the structural mismatch is type-only. Phase 1 implementer chose this rather than
// constraining sdk-trace-base to 1.28.x to avoid losing future bug fixes.
new BatchSpanProcessor(bridge.spanProcessor as never),
]
: [new PiiScrubSpanProcessor()];
// PiiScrubLogRecordProcessor runs FIRST for the same reason.
// SentryLogRecordForwarder forwards synchronously (no batching wrapper needed).
const logRecordProcessors = bridge.logRecordProcessor
? [new PiiScrubLogRecordProcessor(), bridge.logRecordProcessor]
: [new PiiScrubLogRecordProcessor()];
// In-process MeterProvider with no reader/exporter. Sentry metrics ingestion
// is experimental in @sentry/opentelemetry 10.x and not wired here; metrics
// emit through the API but are not exported anywhere. A future phase can add
// a PeriodicExportingMetricReader when a vendor exporter is available.
const metricReader = undefined;
const sdk = new NodeSDK({
resource,
spanProcessors,
logRecordProcessors,
metricReader,
});
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;
}

View File

@@ -0,0 +1,230 @@
// packages/core-shared/src/instrumentation/otel/otel-logger.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { context, trace } from "@opentelemetry/api";
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import {
LoggerProvider,
InMemoryLogRecordExporter,
SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OtelLogger } from "./otel-logger";
// Enable async context manager for span context propagation
const ctxManager = new AsyncLocalStorageContextManager();
ctxManager.enable();
context.setGlobalContextManager(ctxManager);
function setupProviders(): {
logExporter: InMemoryLogRecordExporter;
spanExporter: InMemorySpanExporter;
loggerProvider: LoggerProvider;
tracerProvider: BasicTracerProvider;
} {
const logExporter = new InMemoryLogRecordExporter();
const loggerProvider = new LoggerProvider();
loggerProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter));
logs.setGlobalLoggerProvider(loggerProvider);
const spanExporter = new InMemorySpanExporter();
const tracerProvider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(spanExporter)],
});
trace.setGlobalTracerProvider(tracerProvider);
return { logExporter, spanExporter, loggerProvider, tracerProvider };
}
describe("OtelLogger", () => {
let logExporter: InMemoryLogRecordExporter;
let spanExporter: InMemorySpanExporter;
let loggerProvider: LoggerProvider;
let tracerProvider: BasicTracerProvider;
beforeEach(() => {
({ logExporter, spanExporter, loggerProvider, tracerProvider } = setupProviders());
});
afterEach(async () => {
await loggerProvider.shutdown();
await tracerProvider.shutdown();
logs.disable();
trace.disable();
});
describe("captureException", () => {
it("emits a log record with ERROR severity and exception attributes", () => {
const logger = new OtelLogger();
const err = new Error("something broke");
err.name = "CustomError";
logger.captureException(err, { tags: { feature: "blog" }, extras: { userId: "u1" } });
const records = logExporter.getFinishedLogRecords();
expect(records).toHaveLength(1);
const [record] = records;
expect(record!.severityNumber).toBe(SeverityNumber.ERROR);
expect(record!.severityText).toBe("ERROR");
expect(record!.body).toBe("something broke");
expect(record!.attributes["exception.type"]).toBe("CustomError");
expect(record!.attributes["exception.message"]).toBe("something broke");
expect(typeof record!.attributes["exception.stacktrace"]).toBe("string");
// Tags are prefixed with "tag."
expect(record!.attributes["tag.feature"]).toBe("blog");
// Extras are prefixed with "extra."
expect(record!.attributes["extra.userId"]).toBe("u1");
});
it("applies sentry.fingerprint attribute when provided", () => {
const logger = new OtelLogger();
const err = new Error("fingerprinted");
logger.captureException(err, { fingerprint: ["type-a", "src-blog"] });
const [record] = logExporter.getFinishedLogRecords();
expect(record!.attributes["sentry.fingerprint"]).toBe("type-a|src-blog");
});
it("is a no-op on second call for the same error (double-report guard)", () => {
const logger = new OtelLogger();
const err = new Error("once");
logger.captureException(err);
logger.captureException(err); // should be skipped
expect(logExporter.getFinishedLogRecords()).toHaveLength(1);
});
it("marks error as reported after first call", () => {
const logger = new OtelLogger();
const err = new Error("mark-test");
logger.captureException(err);
expect(
(err as unknown as Record<string, unknown>)["__sentryReported"],
).toBe(true);
});
it("wraps non-Error values into an Error object", () => {
const logger = new OtelLogger();
logger.captureException("plain string error");
const [record] = logExporter.getFinishedLogRecords();
expect(record!.body).toBe("plain string error");
expect(record!.attributes["exception.message"]).toBe("plain string error");
});
});
describe("captureMessage severity mapping", () => {
it.each([
["info" as const, SeverityNumber.INFO, "INFO"],
["warning" as const, SeverityNumber.WARN, "WARNING"],
["error" as const, SeverityNumber.ERROR, "ERROR"],
] as const)(
"level %s → severityNumber %d, severityText %s",
(level, expectedNumber, expectedText) => {
const logger = new OtelLogger();
logger.captureMessage("test message", level);
const [record] = logExporter.getFinishedLogRecords();
expect(record!.severityNumber).toBe(expectedNumber);
expect(record!.severityText).toBe(expectedText);
expect(record!.body).toBe("test message");
},
);
it("defaults to INFO when level is omitted", () => {
const logger = new OtelLogger();
logger.captureMessage("default level");
const [record] = logExporter.getFinishedLogRecords();
expect(record!.severityNumber).toBe(SeverityNumber.INFO);
expect(record!.severityText).toBe("INFO");
});
it("includes tags and extras as prefixed attributes", () => {
const logger = new OtelLogger();
logger.captureMessage("msg", "warning", {
tags: { service: "auth" },
extras: { count: 5 },
});
const [record] = logExporter.getFinishedLogRecords();
expect(record!.attributes["tag.service"]).toBe("auth");
expect(record!.attributes["extra.count"]).toBe("5");
});
});
describe("addBreadcrumb", () => {
it("adds a span event when there is an active span", async () => {
const logger = new OtelLogger();
const otelTracer = trace.getTracer("test");
await otelTracer.startActiveSpan("test-span", async (span) => {
logger.addBreadcrumb({
category: "http",
message: "GET /api/blog",
level: "info",
data: { status: 200 },
});
span.end();
});
const spans = spanExporter.getFinishedSpans();
expect(spans).toHaveLength(1);
const [span] = spans;
const breadcrumbEvent = span!.events.find((e) => e.name === "GET /api/blog");
expect(breadcrumbEvent).toBeDefined();
expect(breadcrumbEvent!.attributes!["breadcrumb.category"]).toBe("http");
expect(breadcrumbEvent!.attributes!["breadcrumb.level"]).toBe("info");
expect(breadcrumbEvent!.attributes!["extra.status"]).toBe("200");
});
it("is a no-op when there is no active span", () => {
const logger = new OtelLogger();
// No span active — should not throw
expect(() =>
logger.addBreadcrumb({ category: "nav", message: "page changed" }),
).not.toThrow();
});
});
describe("setUser", () => {
it("sets user.id attribute on the active span", async () => {
const logger = new OtelLogger();
const otelTracer = trace.getTracer("test");
await otelTracer.startActiveSpan("user-span", async (span) => {
logger.setUser({ id: "user-123" });
span.end();
});
const [span] = spanExporter.getFinishedSpans();
expect(span!.attributes["user.id"]).toBe("user-123");
});
it("sets user.id to empty string when called with null", async () => {
const logger = new OtelLogger();
const otelTracer = trace.getTracer("test");
await otelTracer.startActiveSpan("logout-span", async (span) => {
logger.setUser(null);
span.end();
});
const [span] = spanExporter.getFinishedSpans();
expect(span!.attributes["user.id"]).toBe("");
});
it("is a no-op when there is no active span", () => {
const logger = new OtelLogger();
expect(() => logger.setUser({ id: "u1" })).not.toThrow();
});
});
});

View File

@@ -0,0 +1,76 @@
// packages/core-shared/src/instrumentation/otel/otel-logger.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 =
level === "error"
? SeverityNumber.ERROR
: level === "warning"
? SeverityNumber.WARN
: SeverityNumber.INFO;
const severityText =
level === "error" ? "ERROR" : level === "warning" ? "WARNING" : "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 : String(v),
]),
);
}

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
import { OtelMetrics } from "./otel-metrics";
function setupMeterProvider(): {
exporter: InMemoryMetricExporter;
provider: MeterProvider;
} {
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 100,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
return { exporter, provider };
}
describe("OtelMetrics", () => {
let exporter: InMemoryMetricExporter;
let provider: MeterProvider;
beforeEach(() => {
({ exporter, provider } = setupMeterProvider());
});
afterEach(async () => {
await provider.shutdown();
metrics.disable();
});
it("counter() records a counter measurement", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.counter("http.requests", 1, { method: "GET" });
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const counterMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "http.requests");
expect(counterMetric).toBeDefined();
expect(counterMetric!.dataPoints).toHaveLength(1);
expect(counterMetric!.dataPoints[0]!.value).toBe(1);
});
it("counter() defaults value to 1 when omitted", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.counter("events.processed");
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const counterMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "events.processed");
expect(counterMetric).toBeDefined();
expect(counterMetric!.dataPoints[0]!.value).toBe(1);
});
it("histogram() records a histogram measurement", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.histogram("http.duration", 250, { route: "/api/me" });
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const histogramMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "http.duration");
expect(histogramMetric).toBeDefined();
expect(histogramMetric!.dataPoints).toHaveLength(1);
// Histogram data points carry a Histogram aggregate value with sum/count/buckets.
const dp = histogramMetric!.dataPoints[0] as {
value: { sum?: number; count: number };
};
expect(dp.value.sum).toBe(250);
expect(dp.value.count).toBe(1);
});
it("gauge() records via UpDownCounter", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.gauge("queue.depth", 5, { queue: "emails" });
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const gaugeMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "queue.depth");
expect(gaugeMetric).toBeDefined();
expect(gaugeMetric!.dataPoints).toHaveLength(1);
});
it("lazily caches instrument instances — same counter object reused across calls", async () => {
const otelMetrics = new OtelMetrics();
otelMetrics.counter("reuse.test", 1);
otelMetrics.counter("reuse.test", 2);
await provider.forceFlush();
const metrics_ = exporter.getMetrics();
const counterMetric = metrics_
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "reuse.test");
// Cumulative: should accumulate both adds (1+2=3)
expect(counterMetric!.dataPoints[0]!.value).toBe(3);
});
});

View File

@@ -0,0 +1,70 @@
import { metrics } from "@opentelemetry/api";
import type { Counter, Histogram, UpDownCounter } from "@opentelemetry/api";
import type { IMetrics, MetricAttributeValue } from "../metrics.interface";
/**
* OTel-backed IMetrics implementation.
*
* - counter → OTel Counter (monotonic, add-only)
* - histogram → OTel Histogram
* - gauge → OTel UpDownCounter (synchronous emit). Known limitation: this
* accumulates deltas, not point-in-time values. True "set to
* absolute" semantics require ObservableGauge with a callback;
* deferred to a v2 interface.
*
* Instrument instances are lazily created and cached per name so repeated
* calls to the same metric name reuse the same OTel instrument.
*/
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 = 1,
attributes?: Record<string, MetricAttributeValue>,
): void {
let counter = this.counters.get(name);
if (!counter) {
counter = this.meter.createCounter(name);
this.counters.set(name, counter);
}
counter.add(value, attributes);
}
histogram(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void {
let histogram = this.histograms.get(name);
if (!histogram) {
histogram = this.meter.createHistogram(name);
this.histograms.set(name, histogram);
}
histogram.record(value, attributes);
}
/**
* Records a gauge value via UpDownCounter.
*
* Note: UpDownCounter accumulates a running delta — each call adds to the
* previous value rather than replacing it. This is a synchronous approximation
* of gauge semantics. For true "set to absolute value" behaviour, use an
* ObservableGauge with a periodic callback instead (a future v2 addition).
*/
gauge(
name: string,
value: number,
attributes?: Record<string, MetricAttributeValue>,
): void {
let gauge = this.gauges.get(name);
if (!gauge) {
gauge = this.meter.createUpDownCounter(name);
this.gauges.set(name, gauge);
}
gauge.add(value, attributes);
}
}

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { context, trace } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OtelTracer } from "./otel-tracer";
// Register the async context manager once for the entire test file.
// This must be done before any OtelTracer is constructed.
const ctxManager = new AsyncLocalStorageContextManager();
ctxManager.enable();
context.setGlobalContextManager(ctxManager);
function setupProvider(): { exporter: InMemorySpanExporter; provider: BasicTracerProvider } {
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
return { exporter, provider };
}
describe("OtelTracer", () => {
let exporter: InMemorySpanExporter;
let provider: BasicTracerProvider;
beforeEach(() => {
({ exporter, provider } = setupProvider());
});
afterEach(async () => {
await provider.shutdown();
trace.disable();
});
it("records span name and op attribute", async () => {
const tracer = new OtelTracer();
await tracer.startSpan({ name: "blog.getArticles", op: "use-case" }, async () => "value");
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
const [span] = spans;
expect(span!.name).toBe("blog.getArticles");
expect(span!.attributes["span.op"]).toBe("use-case");
});
it("records additional attributes, filtering out null values", async () => {
const tracer = new OtelTracer();
await tracer.startSpan(
{
name: "articles.findAll",
op: "repository",
attributes: { collection: "articles", limit: 10, tag: null },
},
async () => undefined,
);
const [span] = exporter.getFinishedSpans();
expect(span!.attributes["collection"]).toBe("articles");
expect(span!.attributes["limit"]).toBe(10);
expect(span!.attributes["tag"]).toBeUndefined();
});
it("nested spans — child span has parent span as its parent", async () => {
const tracer = new OtelTracer();
await tracer.startSpan({ name: "parent" }, async () => {
await tracer.startSpan({ name: "child" }, async () => "child-result");
return "parent-result";
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(2);
const child = spans.find((s) => s.name === "child")!;
const parent = spans.find((s) => s.name === "parent")!;
// In sdk-trace-base@1.30.x the parent-child link is tracked via parentSpanId (string)
// Both spans are in the same trace
expect(child.spanContext().traceId).toBe(parent.spanContext().traceId);
// The child's parentSpanId should be the parent's span ID
expect(child.parentSpanId).toBe(parent.spanContext().spanId);
});
it("records exception and sets ERROR status on throw", async () => {
const tracer = new OtelTracer();
await expect(
tracer.startSpan({ name: "failing-op" }, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
const [span] = exporter.getFinishedSpans();
expect(span!.status.code).toBe(2); // SpanStatusCode.ERROR = 2
const exceptionEvent = span!.events.find((e) => e.name === "exception");
expect(exceptionEvent).toBeDefined();
expect(exceptionEvent!.attributes!["exception.message"]).toBe("boom");
});
it("ISpan adapter: setAttribute ignores null; setStatus maps ok/error", async () => {
const tracer = new OtelTracer();
await tracer.startSpan({ name: "adapter-test" }, async (span) => {
span.setAttribute("key", "value");
span.setAttribute("nullable", null);
span.setStatus("ok");
return undefined;
});
const [span] = exporter.getFinishedSpans();
expect(span!.attributes["key"]).toBe("value");
expect(span!.attributes["nullable"]).toBeUndefined();
expect(span!.status.code).toBe(1); // SpanStatusCode.OK = 1
});
});

View File

@@ -0,0 +1,46 @@
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import type { ITracer, ISpan, SpanOpts } 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> = {
...(opts.attributes
? (Object.fromEntries(
Object.entries(opts.attributes).filter(([, v]) => v !== null),
) as Record<string, string | number | boolean>)
: {}),
...(opts.op ? { "span.op": opts.op } : {}),
};
return this.tracer.startActiveSpan(
opts.name,
{ kind: SpanKind.INTERNAL, attributes },
async (otelSpan) => {
const adapter: ISpan = {
setAttribute(key, value) {
if (value !== null) {
otelSpan.setAttribute(key, value as string | number | boolean);
}
},
setStatus(status, message) {
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();
}
},
);
}
}

View File

@@ -0,0 +1,67 @@
// packages/core-shared/src/instrumentation/otel/pii-fields.ts
// R32 — substring match on event keys (case-insensitive).
// IP address attribute KEYS from OTel HttpInstrumentation (semconv 1.20 and 1.27+)
// are listed here so they are key-redacted in addition to the value-level regex
// scrubbing in pii-scrub-processor.ts.
export const PII_KEY_SUBSTRINGS = [
"email",
"password",
"token",
"cookie",
"authorization",
"set-cookie",
"x-api-key",
"apikey",
"api_key",
"secret",
"ipaddress",
// OTel HTTP semantic conventions — IP / client address attributes
"client.address",
"client_ip",
"client.ip",
"net.peer.ip",
"net.sock.peer.addr",
"net.peer.addr",
"http.client_ip",
"server.address",
"host.ip",
] as const;
// R33 — substring match on URL query-param keys (case-insensitive)
export const PII_QUERY_PARAM_SUBSTRINGS = [
"token",
"email",
"password",
"key",
"sig",
"signature",
"access_token",
"accesstoken",
"secret",
] as const;
export const REDACTED_VALUE = "[redacted]" as const;
export const REDACTED_IP = "[redacted-ip]" as const;
// IPv4: simple dotted-quad.
export const IPV4_REGEX = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;
// IPv6: covers the three common forms:
// 1. Full / no-:: form: 2001:0db8:0000:0000:0000:0000:0000:0001
// 2. Compressed prefix::rest: 2001:0db8::1 (one or more groups before ::)
// 3. Leading ::suffix: ::1 or ::ffff:192.0.2.1
// The alternation order puts the longer prefix:: pattern first so it captures
// the full address rather than leaving the prefix unmatched.
export const IPV6_REGEX =
/\b(?:[0-9a-fA-F]{1,4}:){1,7}:[0-9a-fA-F]{0,4}\b|\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{0,4}/g;
export function keyContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}
export function queryParamContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s));
}

View File

@@ -0,0 +1,176 @@
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 } from "@opentelemetry/api-logs";
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
const spanExporter = new InMemorySpanExporter();
const tracerProvider = new BasicTracerProvider({
spanProcessors: [new PiiScrubSpanProcessor(), new SimpleSpanProcessor(spanExporter)],
});
// Use addLogRecordProcessor to chain processors in the right order.
const logExporter = new InMemoryLogRecordExporter();
const logProvider = new LoggerProvider();
logProvider.addLogRecordProcessor(new PiiScrubLogRecordProcessor());
logProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter));
beforeEach(() => {
spanExporter.reset();
logExporter.reset();
});
describe("PiiScrubSpanProcessor", () => {
it("redacts attributes whose names contain PII substrings", () => {
const tracer = tracerProvider.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");
});
it("preserves non-PII attributes unchanged", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: {
"http.method": "GET",
"span.op": "use-case",
feature: "blog",
},
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["http.method"]).toBe("GET");
expect(exported[0]!.attributes["span.op"]).toBe("use-case");
expect(exported[0]!.attributes["feature"]).toBe("blog");
});
it("redacts attributes with cookie and apikey substrings", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: {
"request.cookie": "session=xyz",
"x-api-key": "key123",
"secret.value": "mysecret",
},
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["request.cookie"]).toBe("[redacted]");
expect(exported[0]!.attributes["x-api-key"]).toBe("[redacted]");
expect(exported[0]!.attributes["secret.value"]).toBe("[redacted]");
});
});
describe("PiiScrubLogRecordProcessor", () => {
it("redacts log record attributes whose names contain PII substrings", () => {
const logger = logProvider.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 = logProvider.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]");
});
it("preserves log body when it contains no PII substrings", () => {
const logger = logProvider.getLogger("test");
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: "user signed in successfully",
});
const records = logExporter.getFinishedLogRecords();
expect(records[0]!.body).toBe("user signed in successfully");
});
it("scrubs IPv4 in log record body (C2 / R32)", () => {
const logger = logProvider.getLogger("test");
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: "request from 10.0.0.1 finished",
});
const records = logExporter.getFinishedLogRecords();
expect(records[0]!.body).toContain("[redacted-ip]");
expect(records[0]!.body).not.toContain("10.0.0.1");
});
});
describe("PiiScrubSpanProcessor — IP address scrubbing (C2 / R32)", () => {
it("scrubs IPv4 addresses in attribute values", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: { "request.note": "request from 10.0.0.1" },
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["request.note"]).toBe("request from [redacted-ip]");
});
it("scrubs IPv6 addresses in attribute values", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: { "request.note": "request from 2001:0db8::1" },
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["request.note"]).toContain("[redacted-ip]");
expect(exported[0]!.attributes["request.note"]).not.toContain("2001:0db8");
});
it("redacts http.client_ip via key match (semconv 1.20)", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: { "http.client_ip": "10.0.0.1" },
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["http.client_ip"]).toBe("[redacted]");
});
it("redacts client.address via key match (semconv 1.27+)", () => {
const tracer = tracerProvider.getTracer("test");
const span = tracer.startSpan("test-span", {
attributes: { "client.address": "10.0.0.1" },
});
span.end();
const exported = spanExporter.getFinishedSpans();
expect(exported[0]!.attributes["client.address"]).toBe("[redacted]");
});
});

View File

@@ -0,0 +1,111 @@
// packages/core-shared/src/instrumentation/otel/pii-scrub-processor.ts
//
// PII scrub processors for OTel spans and log records.
// These run FIRST in their respective processor chains so downstream exporters
// (including the Sentry exporter) see scrubbed data. This replaces the old
// Sentry beforeSend / beforeSendTransaction hooks (R32, R33) — scrubbing now
// happens at the OTel layer, vendor-agnostic.
import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base";
import type { Span } from "@opentelemetry/api";
import type { Context } from "@opentelemetry/api";
import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs";
import { PII_KEY_SUBSTRINGS, REDACTED_VALUE, IPV4_REGEX, IPV6_REGEX, REDACTED_IP } from "./pii-fields";
function isPiiKey(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}
function containsPiiSubstring(s: string): boolean {
const lower = s.toLowerCase();
return PII_KEY_SUBSTRINGS.some((sub) => lower.includes(sub));
}
/**
* Scrubs IP addresses from a string value using regex replacement.
* Called for attribute values whose KEYS did not match a PII substring — the
* old Sentry beforeSend hook performed this kind of value-level scrubbing; we
* replicate it here so IP addresses embedded in non-IP-keyed attributes
* (e.g. "request.note": "from 10.0.0.1") are still redacted (C2 fix / R32).
*/
function scrubValue(value: unknown): unknown {
if (typeof value !== "string") return value;
// The regexes are global (`/g`) so they must be reset between calls via new RegExp
// or by relying on the fact that each string replace runs against a fresh lastIndex.
// String.prototype.replace with a regex literal (global) resets lastIndex automatically.
let scrubbed = value.replace(IPV4_REGEX, REDACTED_IP);
scrubbed = scrubbed.replace(IPV6_REGEX, REDACTED_IP);
return scrubbed;
}
function scrubAttributes(attrs: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(attrs)) {
if (isPiiKey(key)) {
out[key] = REDACTED_VALUE;
} else {
out[key] = scrubValue(value);
}
}
return out;
}
/**
* Runs FIRST in the span processor chain so downstream exporters see scrubbed attributes.
* Redacts any span attribute whose key contains a PII substring (case-insensitive).
* R32 — attribute-key-based PII redaction.
*/
export class PiiScrubSpanProcessor implements SpanProcessor {
forceFlush(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
onStart(_span: Span, _parentContext: Context): void {
// no-op — scrub on completion when all attributes are set
}
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 (key-based substring match, case-insensitive).
* - Strips PII from the log body string (substring match — if any PII substring
* appears in the body, the entire body is redacted to avoid partial leakage).
* R32 — attribute-key-based PII redaction; R33 — body-level redaction.
*/
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") {
if (containsPiiSubstring(record.body)) {
// Body contains a PII keyword (email, password, etc.) — redact entirely
// to avoid partial leakage.
record.body = REDACTED_VALUE;
} else {
// No PII keyword, but may still contain IP addresses embedded in text.
// Apply value-level regex scrubbing (C2 fix / R32).
record.body = scrubValue(record.body) as string;
}
}
}
}

View File

@@ -0,0 +1,38 @@
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();
});
});

View File

@@ -0,0 +1,22 @@
import { 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 new Resource(attrs);
}

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { LogRecord } from "@opentelemetry/sdk-logs";
beforeEach(() => vi.resetModules());
// Use raw OTel SeverityNumber values to avoid module-reset issues with the
// top-level SeverityNumber import in test.each definitions.
// SeverityNumber.INFO = 9, SeverityNumber.WARN = 13, SeverityNumber.ERROR = 17
const SEVERITY_INFO = 9;
const SEVERITY_WARN = 13;
const SEVERITY_ERROR = 17;
describe("createSentryOtelBridge", () => {
it("returns a span processor and log record processor when given a DSN", async () => {
vi.doMock("@sentry/opentelemetry", () => ({
SentrySpanProcessor: class {
onStart() {}
onEnd() {}
forceFlush() {
return Promise.resolve();
}
shutdown() {
return Promise.resolve();
}
},
}));
vi.doMock("@sentry/nextjs", () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
}));
const { createSentryOtelBridge } = await import("./sentry-bridge");
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
expect(bridge.spanProcessor).toBeDefined();
// Phase 3: logRecordProcessor is now wired (SentryLogRecordForwarder)
expect(bridge.logRecordProcessor).toBeDefined();
expect(bridge.logRecordProcessor).not.toBeNull();
});
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();
});
});
describe("SentryLogRecordForwarder", () => {
it("calls Sentry.captureException for ERROR records with exception attributes", async () => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
const record = {
severityNumber: SEVERITY_ERROR,
attributes: {
"exception.type": "TypeError",
"exception.message": "Cannot read property",
"exception.stacktrace": "TypeError: ...\n at foo.ts:10",
"tag.feature": "blog",
"extra.count": "5",
"sentry.fingerprint": "type-a|src-blog",
},
body: "Cannot read property",
} as unknown as LogRecord;
forwarder.onEmit(record);
expect(captureException).toHaveBeenCalledTimes(1);
const [err, opts] = captureException.mock.calls[0]!;
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("TypeError");
expect(err.message).toBe("Cannot read property");
expect(err.stack).toBe("TypeError: ...\n at foo.ts:10");
expect(opts.tags).toEqual({ feature: "blog" });
expect(opts.extra).toEqual({ count: "5" });
expect(opts.fingerprint).toEqual(["type-a", "src-blog"]);
expect(captureMessage).not.toHaveBeenCalled();
});
it("calls Sentry.captureException for ERROR records without exception.stacktrace", async () => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
const record = {
severityNumber: SEVERITY_ERROR,
attributes: {
"exception.message": "Something failed",
},
body: "Something failed",
} as unknown as LogRecord;
forwarder.onEmit(record);
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureMessage).not.toHaveBeenCalled();
});
it.each([
[SEVERITY_INFO, "info"],
[SEVERITY_WARN, "warning"],
] as const)(
"calls Sentry.captureMessage with level '%s' for non-error severity %d",
async (severityNumber, expectedLevel) => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
const record = {
severityNumber,
attributes: { "tag.service": "auth", "extra.req": "abc" },
body: "log message",
} as unknown as LogRecord;
forwarder.onEmit(record);
expect(captureMessage).toHaveBeenCalledTimes(1);
const [msg, level, opts] = captureMessage.mock.calls[0]!;
expect(msg).toBe("log message");
expect(level).toBe(expectedLevel);
expect(opts.tags).toEqual({ service: "auth" });
expect(opts.extra).toEqual({ req: "abc" });
expect(captureException).not.toHaveBeenCalled();
},
);
it("forceFlush and shutdown resolve immediately", async () => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const forwarder = new SentryLogRecordForwarder({
captureException: vi.fn(),
captureMessage: vi.fn(),
});
await expect(forwarder.forceFlush()).resolves.toBeUndefined();
await expect(forwarder.shutdown()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,136 @@
import type { tracing as sdkTracing, logs as sdkLogs } from "@opentelemetry/sdk-node";
import { SeverityNumber } from "@opentelemetry/api-logs";
import type { LogRecord } from "@opentelemetry/sdk-logs";
type SpanProcessor = sdkTracing.SpanProcessor;
type LogRecordProcessor = sdkLogs.LogRecordProcessor;
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;
};
type SentryModule = {
captureException: (
err: Error,
opts: {
tags?: Record<string, string>;
extra?: Record<string, unknown>;
fingerprint?: string[];
},
) => void;
captureMessage: (
msg: string,
level: string,
opts: { tags?: Record<string, string>; extra?: Record<string, unknown> },
) => void;
};
/**
* Consumes OTel LogRecords and forwards them to Sentry via the user-facing
* Sentry SDK API. This is the Sentry-coupled bridge — the only file in
* core-shared (besides sentry/init-server.ts etc.) that imports `@sentry/*`.
*
* Double-report note: ERROR records may also arrive via the span-event path
* (OtelTracer.recordException → SentrySpanProcessor). Sentry's native dedup
* handles this (same stack + message). Future hardening can refine.
*
* @param sentry — injectable Sentry module reference; defaults to lazy-require
* of `@sentry/nextjs`. Pass a mock in tests to avoid require() interception
* limitations with Vitest's vi.doMock.
*/
export class SentryLogRecordForwarder implements LogRecordProcessor {
private readonly sentry: SentryModule;
constructor(sentry?: SentryModule) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
this.sentry = sentry ?? (require("@sentry/nextjs") as SentryModule);
}
onEmit(record: LogRecord): void {
const Sentry = this.sentry;
const attrs = record.attributes ?? {};
const tags = extractTags(attrs);
const extra = extractExtras(attrs);
const severityNumber = record.severityNumber ?? SeverityNumber.INFO;
if (severityNumber >= SeverityNumber.ERROR) {
// Reconstruct the error from OTel semantic convention attributes
const message =
(attrs["exception.message"] as string | undefined) ?? String(record.body ?? "");
const err = new Error(message);
if (attrs["exception.type"]) {
err.name = attrs["exception.type"] as string;
}
if (attrs["exception.stacktrace"]) {
err.stack = attrs["exception.stacktrace"] as string;
}
const fingerprint = attrs["sentry.fingerprint"]
? (attrs["sentry.fingerprint"] as string).split("|")
: undefined;
Sentry.captureException(err, { tags, extra, ...(fingerprint ? { fingerprint } : {}) });
} else {
// Map severity to Sentry level
const level = severityNumber >= SeverityNumber.WARN ? "warning" : "info";
Sentry.captureMessage(String(record.body ?? ""), level, { tags, extra });
}
}
forceFlush(): Promise<void> {
return Promise.resolve();
}
shutdown(): Promise<void> {
return Promise.resolve();
}
}
/**
* 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() as SpanProcessor,
logRecordProcessor: new SentryLogRecordForwarder(),
};
}
// ── Attribute helpers ────────────────────────────────────────────────────────
function extractTags(attrs: Record<string, unknown>): Record<string, string> {
const tags: Record<string, string> = {};
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith("tag.")) {
tags[k.slice(4)] = String(v);
}
}
return tags;
}
function extractExtras(attrs: Record<string, unknown>): Record<string, unknown> {
const extras: Record<string, unknown> = {};
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith("extra.")) {
extras[k.slice(6)] = v;
}
}
return extras;
}

View File

@@ -1,7 +1,65 @@
// packages/core-shared/src/instrumentation/sentry/init-client-react.ts
// Browser-side Sentry init for Vite/React runtimes (TanStack Start). PII scrubbing is
// applied via beforeSend/beforeSendTransaction because browser does NOT use the OTel pipeline.
// PII field lists imported from otel/pii-fields.ts (vendor-neutral).
import * as SentryReact from "@sentry/react";
import { beforeSend, beforeSendTransaction } from "./scrub";
import type { InitClientOpts } from "./init-client";
import {
PII_KEY_SUBSTRINGS,
PII_QUERY_PARAM_SUBSTRINGS,
REDACTED_VALUE,
REDACTED_IP,
IPV4_REGEX,
IPV6_REGEX,
} from "../otel/pii-fields";
// R32 — inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
function keyContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}
function queryParamContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s));
}
function redactString(s: string): string {
const ipv4 = new RegExp(IPV4_REGEX.source, "g");
const ipv6 = new RegExp(IPV6_REGEX.source, "g");
return s.replace(ipv4, REDACTED_IP).replace(ipv6, REDACTED_IP);
}
function deepScrub(value: unknown, parentKey = ""): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
}
if (Array.isArray(value)) return value.map((v) => deepScrub(v, parentKey));
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = keyContainsPii(k) ? REDACTED_VALUE : deepScrub(v, k);
}
return out;
}
return value;
}
function scrubUrl(url: string): string {
try {
const u = new URL(url, "http://placeholder.local");
for (const [k] of Array.from(u.searchParams.entries())) {
if (queryParamContainsPii(k)) u.searchParams.set(k, REDACTED_VALUE);
}
return url.startsWith("/") ? `${u.pathname}${u.search}` : u.toString();
} catch {
return url;
}
}
/**
* Client-side init for non-Next.js (Vite/React) runtimes (TanStack Start).
@@ -24,15 +82,23 @@ export function initSentryClientReact(opts: InitClientOpts): void {
const release = opts.release ?? "unknown";
type InitOpts = Parameters<typeof SentryReact.init>[0];
type SentryEvent = { extra?: Record<string, unknown> | null; contexts?: Record<string, Record<string, unknown> | undefined>; request?: { url?: string; headers?: Record<string, string | undefined>; [key: string]: unknown }; transaction?: string; [key: string]: unknown };
SentryReact.init({
dsn: opts.dsn,
environment,
release,
tracesSampleRate,
sendDefaultPii: false, // R31
beforeSend: beforeSend as unknown as NonNullable<InitOpts>["beforeSend"], // R32
beforeSendTransaction:
beforeSendTransaction as unknown as NonNullable<InitOpts>["beforeSendTransaction"], // R33
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"], // R32
beforeSendTransaction: ((event: SentryEvent) => { // R33
const out = { ...event };
if (out.request?.url) out.request = { ...out.request, url: scrubUrl(out.request.url) };
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
out.transaction = scrubUrl(out.transaction);
}
return out;
}) as unknown as NonNullable<InitOpts>["beforeSendTransaction"],
replaysSessionSampleRate: 0.0, // R37
replaysOnErrorSampleRate: 1.0, // R37
integrations: [

View File

@@ -1,6 +1,16 @@
// packages/core-shared/src/instrumentation/sentry/init-client.ts
// Browser-side Sentry init. PII scrubbing is applied via beforeSend/beforeSendTransaction
// hooks because browser does NOT use the OTel pipeline (server-only migration). The
// PII field lists come from otel/pii-fields.ts (vendor-neutral location).
import * as Sentry from "@sentry/nextjs";
import { beforeSend, beforeSendTransaction } from "./scrub";
import {
PII_KEY_SUBSTRINGS,
PII_QUERY_PARAM_SUBSTRINGS,
REDACTED_VALUE,
REDACTED_IP,
IPV4_REGEX,
IPV6_REGEX,
} from "../otel/pii-fields";
export type InitClientOpts = {
dsn: string | undefined;
@@ -8,6 +18,54 @@ export type InitClientOpts = {
release?: string;
};
// R32 — inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
function keyContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}
function queryParamContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s));
}
function redactString(s: string): string {
const ipv4 = new RegExp(IPV4_REGEX.source, "g");
const ipv6 = new RegExp(IPV6_REGEX.source, "g");
return s.replace(ipv4, REDACTED_IP).replace(ipv6, REDACTED_IP);
}
function deepScrub(value: unknown, parentKey = ""): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
}
if (Array.isArray(value)) return value.map((v) => deepScrub(v, parentKey));
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = keyContainsPii(k) ? REDACTED_VALUE : deepScrub(v, k);
}
return out;
}
return value;
}
function scrubUrl(url: string): string {
try {
const u = new URL(url, "http://placeholder.local");
for (const [k] of Array.from(u.searchParams.entries())) {
if (queryParamContainsPii(k)) u.searchParams.set(k, REDACTED_VALUE);
}
return url.startsWith("/") ? `${u.pathname}${u.search}` : u.toString();
} catch {
return url;
}
}
export function initSentryClient(opts: InitClientOpts): void {
if (!opts.dsn) return;
@@ -24,14 +82,23 @@ export function initSentryClient(opts: InitClientOpts): void {
const release = opts.release ?? "unknown";
type InitOpts = Parameters<typeof Sentry.init>[0];
type SentryEvent = { extra?: Record<string, unknown> | null; contexts?: Record<string, Record<string, unknown> | undefined>; request?: { url?: string; headers?: Record<string, string | undefined>; [key: string]: unknown }; transaction?: string; [key: string]: unknown };
Sentry.init({
dsn: opts.dsn,
environment,
release,
tracesSampleRate,
sendDefaultPii: false, // R31
beforeSend: beforeSend as unknown as InitOpts["beforeSend"], // R32
beforeSendTransaction: beforeSendTransaction as unknown as InitOpts["beforeSendTransaction"], // R33
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as InitOpts["beforeSend"], // R32
beforeSendTransaction: ((event: SentryEvent) => { // R33
const out = { ...event };
if (out.request?.url) out.request = { ...out.request, url: scrubUrl(out.request.url) };
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
out.transaction = scrubUrl(out.transaction);
}
return out;
}) as unknown as InitOpts["beforeSendTransaction"],
replaysSessionSampleRate: 0.0, // R37 — privacy default
replaysOnErrorSampleRate: 1.0, // R37
integrations: [

View File

@@ -1,51 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/init-server-node.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@sentry/node", () => ({
init: vi.fn(),
}));
import * as SentryNode from "@sentry/node";
import { initSentryServerNode } from "@/instrumentation/sentry/init-server-node";
describe("initSentryServerNode", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls SentryNode.init with sendDefaultPii: false (R31)", () => {
initSentryServerNode({ dsn: "https://x@y/1", app: "web-tanstack" });
const call = (SentryNode.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
expect(call["sendDefaultPii"]).toBe(false);
});
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
initSentryServerNode({ dsn: "https://x@y/1", app: "web-tanstack" });
const call = (SentryNode.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
expect(typeof call["beforeSend"]).toBe("function");
expect(typeof call["beforeSendTransaction"]).toBe("function");
});
it("tags events with the app name", () => {
initSentryServerNode({ dsn: "https://x@y/1", app: "web-tanstack" });
const call = (SentryNode.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const initialScope = call["initialScope"] as { tags?: Record<string, string> };
expect(initialScope?.tags?.["app"]).toBe("web-tanstack");
});
it("is a no-op when dsn is missing", () => {
initSentryServerNode({ dsn: "", app: "web-tanstack" });
expect(SentryNode.init).not.toHaveBeenCalled();
initSentryServerNode({ dsn: undefined as unknown as string, app: "web-tanstack" });
expect(SentryNode.init).not.toHaveBeenCalled();
});
});

View File

@@ -1,40 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/init-server-node.ts
import * as SentryNode from "@sentry/node";
import { beforeSend, beforeSendTransaction } from "./scrub";
import type { InitServerOpts } from "./init-server";
/**
* Server-side init for non-Next.js runtimes (TanStack Start). Mirrors
* init-server.ts but uses @sentry/node directly. R31, R32, R33 still apply.
*/
export function initSentryServerNode(opts: InitServerOpts): void {
if (!opts.dsn) return;
const isProd = process.env["NODE_ENV"] === "production";
const tracesSampleRate =
process.env["SENTRY_TRACES_SAMPLE_RATE"] !== undefined
? Number(process.env["SENTRY_TRACES_SAMPLE_RATE"])
: isProd
? 0.1
: 1.0;
const environment =
process.env["SENTRY_ENVIRONMENT"] ??
process.env["VERCEL_ENV"] ??
process.env["NODE_ENV"] ??
"development";
const release = opts.release ?? process.env["VITE_GIT_COMMIT_SHA"] ?? "unknown";
type InitOpts = Parameters<typeof SentryNode.init>[0];
SentryNode.init({
dsn: opts.dsn,
environment,
release,
tracesSampleRate,
sendDefaultPii: false, // R31
beforeSend: beforeSend as unknown as NonNullable<InitOpts>["beforeSend"], // R32
beforeSendTransaction:
beforeSendTransaction as unknown as NonNullable<InitOpts>["beforeSendTransaction"], // R33
initialScope: { tags: { app: opts.app } },
});
}

View File

@@ -1,99 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/init-server.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@sentry/nextjs", () => ({
init: vi.fn(),
replayIntegration: vi.fn(() => ({ name: "Replay" })),
}));
import * as Sentry from "@sentry/nextjs";
import { initSentryServer } from "@/instrumentation/sentry/init-server";
describe("initSentryServer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls Sentry.init with sendDefaultPii: false (R31)", () => {
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
expect(Sentry.init).toHaveBeenCalledTimes(1);
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
expect(call["sendDefaultPii"]).toBe(false);
});
it("passes the configured DSN", () => {
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
expect(call["dsn"]).toBe("https://x@y/1");
});
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
expect(typeof call["beforeSend"]).toBe("function");
expect(typeof call["beforeSendTransaction"]).toBe("function");
});
it("uses SENTRY_TRACES_SAMPLE_RATE env when set", () => {
vi.stubEnv("SENTRY_TRACES_SAMPLE_RATE", "0.25");
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
expect(call["tracesSampleRate"]).toBe(0.25);
vi.unstubAllEnvs();
});
it("defaults tracesSampleRate to 1.0 in dev, 0.1 in production", () => {
// Save and clear rate env to test default logic
const prevRate = process.env["SENTRY_TRACES_SAMPLE_RATE"];
delete process.env["SENTRY_TRACES_SAMPLE_RATE"];
vi.stubEnv("NODE_ENV", "development");
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
expect(
((Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<string, unknown>)[
"tracesSampleRate"
],
).toBe(1.0);
(Sentry.init as ReturnType<typeof vi.fn>).mockClear();
vi.stubEnv("NODE_ENV", "production");
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
expect(
((Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<string, unknown>)[
"tracesSampleRate"
],
).toBe(0.1);
vi.unstubAllEnvs();
if (prevRate !== undefined) process.env["SENTRY_TRACES_SAMPLE_RATE"] = prevRate;
});
it("tags events with the app name", () => {
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
const initialScope = call["initialScope"] as { tags?: Record<string, string> };
expect(initialScope?.tags?.["app"]).toBe("web-next");
});
it("is a no-op when dsn is empty/undefined", () => {
initSentryServer({ dsn: "", app: "web-next" });
expect(Sentry.init).not.toHaveBeenCalled();
initSentryServer({ dsn: undefined as unknown as string, app: "web-next" });
expect(Sentry.init).not.toHaveBeenCalled();
});
});

View File

@@ -1,40 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/init-server.ts
import * as Sentry from "@sentry/nextjs";
import { beforeSend, beforeSendTransaction } from "./scrub";
export type InitServerOpts = {
dsn: string | undefined;
app: "web-next" | "cms" | "web-tanstack";
release?: string;
};
export function initSentryServer(opts: InitServerOpts): void {
if (!opts.dsn) return;
const isProd = process.env["NODE_ENV"] === "production";
const tracesSampleRate =
process.env["SENTRY_TRACES_SAMPLE_RATE"] !== undefined
? Number(process.env["SENTRY_TRACES_SAMPLE_RATE"])
: isProd
? 0.1
: 1.0;
const environment =
process.env["SENTRY_ENVIRONMENT"] ??
process.env["VERCEL_ENV"] ??
process.env["NODE_ENV"] ??
"development";
const release = opts.release ?? process.env["VERCEL_GIT_COMMIT_SHA"] ?? "unknown";
type InitOpts = Parameters<typeof Sentry.init>[0];
Sentry.init({
dsn: opts.dsn,
environment,
release,
tracesSampleRate,
sendDefaultPii: false, // R31 — non-negotiable
beforeSend: beforeSend as unknown as InitOpts["beforeSend"], // R32
beforeSendTransaction: beforeSendTransaction as unknown as InitOpts["beforeSendTransaction"], // R33
initialScope: { tags: { app: opts.app } },
});
}

View File

@@ -1,47 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/pii-fields.ts
// R32 — substring match on event keys (case-insensitive)
export const PII_KEY_SUBSTRINGS = [
"email",
"password",
"token",
"cookie",
"authorization",
"set-cookie",
"x-api-key",
"apikey",
"api_key",
"secret",
"ipaddress",
] as const;
// R33 — substring match on URL query-param keys (case-insensitive)
export const PII_QUERY_PARAM_SUBSTRINGS = [
"token",
"email",
"password",
"key",
"sig",
"signature",
"access_token",
"accesstoken",
"secret",
] as const;
export const REDACTED_VALUE = "[redacted]" as const;
export const REDACTED_IP = "[redacted-ip]" as const;
// IPv4: simple dotted-quad; IPv6: any colon-separated hex with at least one ::
export const IPV4_REGEX = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;
export const IPV6_REGEX =
/\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}/g;
export function keyContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
}
export function queryParamContainsPii(key: string): boolean {
const lower = key.toLowerCase();
return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s));
}

View File

@@ -1,115 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/scrub.test.ts
import { describe, it, expect } from "vitest";
import { beforeSend, beforeSendTransaction } from "@/instrumentation/sentry/scrub";
// Use structural types that match what scrub functions accept
type ScrubEvent = Parameters<typeof beforeSend>[0];
type ScrubHint = Parameters<typeof beforeSend>[1];
type TxEvent = Parameters<typeof beforeSendTransaction>[0];
type TxHint = Parameters<typeof beforeSendTransaction>[1];
const hint = {} as ScrubHint;
const txHint = {} as TxHint;
describe("beforeSend", () => {
it("redacts top-level keys whose names contain PII substrings", () => {
const event = {
extra: { email: "a@b.c", username: "alice" },
contexts: { custom: { password: "p", note: "ok" } },
} as unknown as ScrubEvent;
const result = beforeSend(event, hint) as Record<string, unknown>;
const extra = result["extra"] as Record<string, unknown>;
const contexts = result["contexts"] as { custom: Record<string, unknown> };
expect(extra["email"]).toBe("[redacted]");
expect(extra["username"]).toBe("alice");
expect(contexts.custom["password"]).toBe("[redacted]");
expect(contexts.custom["note"]).toBe("ok");
});
it("redacts derived key names (substring match): userEmail, accessToken, apiKey", () => {
const event = {
extra: { userEmail: "a@b.c", accessToken: "t", apiKey: "k", id: "u1" },
} as unknown as ScrubEvent;
const result = beforeSend(event, hint) as Record<string, unknown>;
const extra = result["extra"] as Record<string, unknown>;
expect(extra["userEmail"]).toBe("[redacted]");
expect(extra["accessToken"]).toBe("[redacted]");
expect(extra["apiKey"]).toBe("[redacted]");
expect(extra["id"]).toBe("u1");
});
it("redacts headers map keys case-insensitively", () => {
const event = {
request: {
headers: { Authorization: "Bearer x", "Set-Cookie": "session=abc", "User-Agent": "ua" },
},
} as unknown as ScrubEvent;
const result = beforeSend(event, hint) as Record<string, unknown>;
const request = result["request"] as { headers: Record<string, unknown> };
expect(request.headers["Authorization"]).toBe("[redacted]");
expect(request.headers["Set-Cookie"]).toBe("[redacted]");
expect(request.headers["User-Agent"]).toBe("ua");
});
it("redacts IPv4 addresses found in string values", () => {
const event = {
extra: { note: "Connection from 192.168.1.10 failed" },
} as unknown as ScrubEvent;
const result = beforeSend(event, hint) as Record<string, unknown>;
const extra = result["extra"] as Record<string, unknown>;
expect(extra["note"]).toBe("Connection from [redacted-ip] failed");
});
it("redacts IPv6 addresses found in string values", () => {
const event = {
extra: { note: "Tunnel to fe80::1ff:fe23:4567:890a established" },
} as unknown as ScrubEvent;
const result = beforeSend(event, hint) as Record<string, unknown>;
const extra = result["extra"] as Record<string, unknown>;
expect(extra["note"] as string).toContain("[redacted-ip]");
});
it("does not crash on null/undefined branches", () => {
expect(beforeSend({ extra: null } as unknown as ScrubEvent, hint)).toBeTruthy();
expect(beforeSend({} as ScrubEvent, hint)).toBeTruthy();
});
it("returns the event (not null) — keeps Sentry transport flowing", () => {
expect(beforeSend({ extra: { ok: true } } as unknown as ScrubEvent, hint)).toBeTruthy();
});
});
describe("beforeSendTransaction", () => {
it("strips PII query params from request.url", () => {
const event = {
request: { url: "https://app/api/foo?token=secret&user=alice&email=a@b.c" },
} as unknown as TxEvent;
const result = beforeSendTransaction(event, txHint) as Record<string, unknown>;
const request = result["request"] as { url: string };
expect(request.url).toContain("token=%5Bredacted%5D");
expect(request.url).toContain("email=%5Bredacted%5D");
expect(request.url).toContain("user=alice");
});
it("strips PII query params from event.transaction", () => {
const event = { transaction: "/foo?token=x&id=y" } as unknown as TxEvent;
const result = beforeSendTransaction(event, txHint) as Record<string, unknown>;
expect(result["transaction"] as string).toContain("token=%5Bredacted%5D");
expect(result["transaction"] as string).toContain("id=y");
});
it("matches derived param names (accessToken, ApiSecret)", () => {
const event = {
request: { url: "https://x/y?accessToken=t&ApiSecret=z&safe=1" },
} as unknown as TxEvent;
const result = beforeSendTransaction(event, txHint) as Record<string, unknown>;
const request = result["request"] as { url: string };
expect(request.url).toContain("accessToken=%5Bredacted%5D");
expect(request.url).toContain("ApiSecret=%5Bredacted%5D");
expect(request.url).toContain("safe=1");
});
it("returns the event when no URL present", () => {
expect(beforeSendTransaction({} as TxEvent, txHint)).toBeTruthy();
});
});

View File

@@ -1,90 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/scrub.ts
// Use structural types matching Sentry's beforeSend/beforeSendTransaction signatures
// to avoid importing @sentry/core types directly (they're not re-exported by @sentry/nextjs).
import {
IPV4_REGEX,
IPV6_REGEX,
REDACTED_IP,
REDACTED_VALUE,
keyContainsPii,
queryParamContainsPii,
} from "./pii-fields";
// Minimal structural types matching Sentry's event shape used in scrubbers.
// Using index signatures broad enough to satisfy both ErrorEvent and TransactionEvent.
type SentryRequest = {
url?: string;
headers?: Record<string, string | undefined>;
[key: string]: unknown;
};
type SentryEvent = {
extra?: Record<string, unknown> | null;
contexts?: Record<string, Record<string, unknown> | undefined>;
request?: SentryRequest;
transaction?: string;
[key: string]: unknown;
};
type SentryEventHint = Record<string, unknown>;
function redactString(s: string): string {
// Create new regexes each call since regexes with /g are stateful
const ipv4 = new RegExp(IPV4_REGEX.source, "g");
const ipv6 = new RegExp(IPV6_REGEX.source, "g");
return s.replace(ipv4, REDACTED_IP).replace(ipv6, REDACTED_IP);
}
function deepScrub(value: unknown, parentKey = ""): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
}
if (Array.isArray(value)) {
return value.map((v) => deepScrub(v, parentKey));
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = keyContainsPii(k) ? REDACTED_VALUE : deepScrub(v, k);
}
return out;
}
return value;
}
export function beforeSend(event: SentryEvent, _hint: SentryEventHint): SentryEvent | null {
return deepScrub(event) as SentryEvent;
}
function scrubUrl(url: string): string {
try {
const u = new URL(url, "http://placeholder.local");
for (const [k] of Array.from(u.searchParams.entries())) {
if (queryParamContainsPii(k)) {
u.searchParams.set(k, REDACTED_VALUE);
}
}
return url.startsWith("/") ? `${u.pathname}${u.search}` : u.toString();
} catch {
return url;
}
}
export function beforeSendTransaction(
event: SentryEvent,
_hint: SentryEventHint,
): SentryEvent | null {
const out: SentryEvent = { ...event };
if (out.request?.url) {
out.request = { ...out.request, url: scrubUrl(out.request.url) };
}
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
out.transaction = scrubUrl(out.transaction);
}
return out;
}

View File

@@ -1,82 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
}));
import * as Sentry from "@sentry/nextjs";
import { SentryLogger } from "@/instrumentation/sentry/sentry-logger";
describe("SentryLogger", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("captureException forwards to Sentry on first call", () => {
const logger = new SentryLogger();
const err = new Error("boom");
logger.captureException(err, { tags: { feature: "blog" } });
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect((Sentry.captureException as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe(err);
});
it("captureException is a no-op when err already marked __sentryReported", () => {
const logger = new SentryLogger();
const err = new Error("already-reported");
Object.defineProperty(err, "__sentryReported", { value: true });
logger.captureException(err);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
it("captureException marks err as __sentryReported after sending", () => {
const logger = new SentryLogger();
const err = new Error("once");
logger.captureException(err);
expect((err as unknown as { __sentryReported: boolean }).__sentryReported).toBe(true);
// Second call: no-op
logger.captureException(err);
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
});
it("__sentryReported is non-enumerable", () => {
const logger = new SentryLogger();
const err = new Error("x");
logger.captureException(err);
expect(Object.keys(err)).not.toContain("__sentryReported");
expect(JSON.stringify(err)).not.toContain("__sentryReported");
});
it("captureMessage forwards to Sentry", () => {
const logger = new SentryLogger();
logger.captureMessage("hello", "warning", { tags: { foo: "bar" } });
expect(Sentry.captureMessage).toHaveBeenCalledWith(
"hello",
expect.objectContaining({ level: "warning" }),
);
});
it("addBreadcrumb forwards to Sentry", () => {
const logger = new SentryLogger();
logger.addBreadcrumb({ category: "test", message: "x", data: { k: "v" } });
expect(Sentry.addBreadcrumb).toHaveBeenCalledTimes(1);
});
it("setUser strips non-id keys and warns in dev", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const logger = new SentryLogger();
logger.setUser({ id: "u1", email: "a@b.c", username: "alice" } as unknown as { id: string });
expect(Sentry.setUser).toHaveBeenCalledWith({ id: "u1" });
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("setUser passes null through", () => {
const logger = new SentryLogger();
logger.setUser(null);
expect(Sentry.setUser).toHaveBeenCalledWith(null);
});
});

View File

@@ -1,45 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/sentry-logger.ts
import * as Sentry from "@sentry/nextjs";
import type { ILogger, Breadcrumb, CaptureContext } from "../logger.interface";
import { isReported, markReported } from "../reported-flag";
export class SentryLogger implements ILogger {
captureException(err: unknown, ctx?: CaptureContext): void {
if (isReported(err)) return;
Sentry.captureException(err, ctx);
markReported(err);
}
captureMessage(
msg: string,
level: "info" | "warning" | "error" = "info",
ctx?: CaptureContext,
): void {
Sentry.captureMessage(msg, { level, ...ctx });
}
addBreadcrumb(b: Breadcrumb): void {
Sentry.addBreadcrumb({
category: b.category,
message: b.message,
level: b.level,
data: b.data,
});
}
setUser(user: { id: string } | null): void {
if (user === null) {
Sentry.setUser(null);
return;
}
const { id, ...extra } = user as { id: string } & Record<string, unknown>;
if (Object.keys(extra).length > 0) {
// R36 — strip non-id keys; warn in dev for visibility
console.warn(
"[SentryLogger.setUser] stripped non-id keys for PII safety:",
Object.keys(extra),
);
}
Sentry.setUser({ id });
}
}

View File

@@ -1,69 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/sentry-tracer.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@sentry/nextjs", () => ({
startSpan: vi.fn((_opts: unknown, fn: (span: unknown) => unknown) =>
fn({ setAttribute: vi.fn(), setStatus: vi.fn() }),
),
}));
import * as Sentry from "@sentry/nextjs";
import { SentryTracer } from "@/instrumentation/sentry/sentry-tracer";
describe("SentryTracer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("delegates startSpan to @sentry/nextjs.startSpan", async () => {
const tracer = new SentryTracer();
const result = await tracer.startSpan(
{ name: "blog.getArticles", op: "use-case" },
async () => "value",
);
expect(result).toBe("value");
expect(Sentry.startSpan).toHaveBeenCalledTimes(1);
expect((Sentry.startSpan as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toMatchObject({
name: "blog.getArticles",
op: "use-case",
});
});
it("forwards attributes to Sentry", async () => {
const tracer = new SentryTracer();
await tracer.startSpan(
{ name: "articles.findAll", op: "repository", attributes: { collection: "articles", limit: 10 } },
async () => undefined,
);
expect(
(Sentry.startSpan as ReturnType<typeof vi.fn>).mock.calls[0]![0].attributes,
).toEqual({
collection: "articles",
limit: 10,
});
});
it("propagates errors from the wrapped function", async () => {
const tracer = new SentryTracer();
await expect(
tracer.startSpan({ name: "x" }, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
});
it("ISpan adapter forwards setAttribute and setStatus to Sentry's span", async () => {
const sentrySpan = { setAttribute: vi.fn(), setStatus: vi.fn() };
(Sentry.startSpan as ReturnType<typeof vi.fn>).mockImplementationOnce(
(_opts: unknown, fn: (span: unknown) => unknown) => fn(sentrySpan),
);
const tracer = new SentryTracer();
await tracer.startSpan({ name: "x" }, async (span) => {
span.setAttribute("k", "v");
span.setStatus("error", "msg");
return undefined;
});
expect(sentrySpan.setAttribute).toHaveBeenCalledWith("k", "v");
expect(sentrySpan.setStatus).toHaveBeenCalled();
});
});

View File

@@ -1,35 +0,0 @@
// packages/core-shared/src/instrumentation/sentry/sentry-tracer.ts
import * as Sentry from "@sentry/nextjs";
import type { ITracer, ISpan, SpanOpts } from "../tracer.interface";
export class SentryTracer implements ITracer {
async startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
// Filter out null values — Sentry SpanAttributes doesn't allow null
const attributes = opts.attributes
? Object.fromEntries(
Object.entries(opts.attributes).filter(([, v]) => v !== null),
) as Record<string, string | number | boolean>
: undefined;
return Sentry.startSpan(
{
name: opts.name,
op: opts.op,
attributes,
},
async (sentrySpan) => {
const adapter: ISpan = {
setAttribute(key, value) {
sentrySpan?.setAttribute?.(key, value as string | number | boolean);
},
setStatus(status, message) {
// Sentry v8+ uses { code: number, message?: string }; we map our enum
const code = status === "ok" ? 1 : 2;
sentrySpan?.setStatus?.({ code, message });
},
};
return fn(adapter);
},
);
}
}

View File

@@ -1,4 +1,5 @@
export const INSTRUMENTATION_SYMBOLS = {
TRACER: Symbol.for("core-shared.TRACER"),
LOGGER: Symbol.for("core-shared.LOGGER"),
METRICS: Symbol.for("core-shared.METRICS"),
} as const;

View File

@@ -12,7 +12,9 @@
"./payload": "./src/payload/index.ts",
"./payload/stub-config": "./src/payload/stub-config.ts",
"./setup/jsdom": "./src/setup/jsdom.ts",
"./setup/node": "./src/setup/node.ts"
"./setup/node": "./src/setup/node.ts",
"./setup/no-instrumentation": "./src/setup/no-instrumentation.ts",
"./setup/no-sentry": "./src/setup/no-instrumentation.ts"
},
"scripts": {
"build": "tsc --noEmit",

View File

@@ -1,5 +1,6 @@
export { RecordingTracer, type RecordedSpan } from "./recording-tracer";
export { RecordingLogger, type RecordedCapture } from "./recording-logger";
export { RecordingMetrics, type RecordedMetric } from "./recording-metrics";
export { RecordingJobQueue } from "./recording-job-queue";
export { RecordingEventBus } from "./recording-event-bus";
export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster";

View File

@@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import { RecordingMetrics } from "@/instrumentation/recording-metrics";
describe("RecordingMetrics", () => {
it("records counter calls with kind, name, value, and attributes", () => {
const recording = new RecordingMetrics();
recording.counter("http.requests", 1, { method: "GET", route: "/api/me" });
expect(recording.metrics).toHaveLength(1);
const [m] = recording.metrics;
expect(m!.kind).toBe("counter");
expect(m!.name).toBe("http.requests");
expect(m!.value).toBe(1);
expect(m!.attributes).toEqual({ method: "GET", route: "/api/me" });
});
it("counter() defaults value to 1 when omitted", () => {
const recording = new RecordingMetrics();
recording.counter("events.signups");
expect(recording.metrics[0]!.value).toBe(1);
});
it("records histogram calls", () => {
const recording = new RecordingMetrics();
recording.histogram("http.duration", 123, { route: "/api/list" });
expect(recording.metrics).toHaveLength(1);
const [m] = recording.metrics;
expect(m!.kind).toBe("histogram");
expect(m!.name).toBe("http.duration");
expect(m!.value).toBe(123);
expect(m!.attributes).toEqual({ route: "/api/list" });
});
it("records gauge calls", () => {
const recording = new RecordingMetrics();
recording.gauge("queue.depth", 42, { queue: "emails" });
expect(recording.metrics).toHaveLength(1);
const [m] = recording.metrics;
expect(m!.kind).toBe("gauge");
expect(m!.name).toBe("queue.depth");
expect(m!.value).toBe(42);
expect(m!.attributes).toEqual({ queue: "emails" });
});
it("accumulates multiple calls across all kinds", () => {
const recording = new RecordingMetrics();
recording.counter("a");
recording.histogram("b", 10);
recording.gauge("c", 5);
expect(recording.metrics).toHaveLength(3);
expect(recording.metrics.map((m) => m.kind)).toEqual([
"counter",
"histogram",
"gauge",
]);
});
it("reset() clears all recorded metrics", () => {
const recording = new RecordingMetrics();
recording.counter("x");
recording.histogram("y", 1);
expect(recording.metrics).toHaveLength(2);
recording.reset();
expect(recording.metrics).toHaveLength(0);
});
it("find() returns the first matching metric", () => {
const recording = new RecordingMetrics();
recording.counter("a");
recording.histogram("b", 10);
const found = recording.find((m) => m.kind === "histogram");
expect(found).toBeDefined();
expect(found!.name).toBe("b");
});
it("find() returns undefined when no metric matches", () => {
const recording = new RecordingMetrics();
recording.counter("a");
const found = recording.find((m) => m.kind === "gauge");
expect(found).toBeUndefined();
});
});

View File

@@ -0,0 +1,66 @@
// Local type alias matching the contract in @repo/core-shared/instrumentation.
// Kept inline to avoid a build-graph cycle between core-testing and core-shared.
type MetricAttributeValue = string | number | boolean;
interface 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;
}
export type RecordedMetric = {
kind: "counter" | "histogram" | "gauge";
name: string;
value: number;
attributes: Record<string, MetricAttributeValue>;
};
export class RecordingMetrics implements IMetrics {
metrics: RecordedMetric[] = [];
counter(
name: string,
value = 1,
attributes: Record<string, MetricAttributeValue> = {},
): void {
this.metrics.push({ kind: "counter", name, value, attributes });
}
histogram(
name: string,
value: number,
attributes: Record<string, MetricAttributeValue> = {},
): void {
this.metrics.push({ kind: "histogram", name, value, attributes });
}
gauge(
name: string,
value: number,
attributes: Record<string, MetricAttributeValue> = {},
): void {
this.metrics.push({ kind: "gauge", name, value, attributes });
}
reset(): void {
this.metrics = [];
}
find(
predicate: (m: RecordedMetric) => boolean,
): RecordedMetric | undefined {
return this.metrics.find(predicate);
}
}

View File

@@ -1,4 +1,4 @@
import "./no-sentry";
import "./no-instrumentation";
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import * as Sentry from "@sentry/nextjs";
describe("setup/no-sentry guard (R49)", () => {
describe("setup/no-instrumentation guard (R49)", () => {
it("Sentry.init is a vi.fn (mocked, not real)", () => {
expect(vi.isMockFunction(Sentry.init)).toBe(true);
});

View File

@@ -1,12 +1,14 @@
import { vi } from "vitest";
/**
* R49 guard against real Sentry SDK initialization in test processes.
* R49 guard against real Sentry SDK + OTel SDK initialization in test processes.
*
* Mocks @sentry/nextjs at the module level so any code that imports it
* receives a no-op surface. Tests that need to assert Sentry behavior
* still use vi.mock locally with their own implementation; this guard
* just ensures *unintentional* imports don't cause real network/init.
* Mocks @sentry/* and key @opentelemetry/sdk-* modules at the module level so
* any code that imports them receives a no-op surface. Tests that need to assert
* specific SDK behavior still use vi.mock locally with their own implementation;
* this guard just ensures *unintentional* imports don't cause real network/init.
*
* Also exported as ./setup/no-sentry for one release cycle (backward-compat alias).
*/
vi.mock("@sentry/nextjs", () => ({
init: vi.fn(),
@@ -60,3 +62,36 @@ vi.mock("@sentry/react", () => ({
),
replayIntegration: vi.fn(() => ({ name: "Replay" })),
}));
// OTel SDK mocks — prevent real SDK initialization in vitest runs.
// Feature packages and core-shared instrumentation code import these; without
// mocks the NodeSDK would attempt to bootstrap a real tracer/logger provider.
vi.mock("@opentelemetry/sdk-node", () => ({
NodeSDK: class {
start() {}
shutdown() {
return Promise.resolve();
}
},
// Re-export tracing namespace so destructured imports work
tracing: {
BatchSpanProcessor: class {
onStart() {}
onEnd() {}
forceFlush() { return Promise.resolve(); }
shutdown() { return Promise.resolve(); }
},
},
}));
vi.mock("@sentry/opentelemetry", () => ({
SentrySpanProcessor: class {
onStart() {}
onEnd() {}
forceFlush() { return Promise.resolve(); }
shutdown() { return Promise.resolve(); }
},
// SentryLogRecordProcessor does NOT exist in @sentry/opentelemetry v10 — omitted.
// No-op Sentry.init wrapper used by sentry-bridge.ts
init: vi.fn(),
}));

View File

@@ -1,4 +1,4 @@
import "./no-sentry";
import "./no-instrumentation";
// Reserved for future global node-env setup. Currently a no-op so that
// vitest configs may reference @repo/core-testing/setup/node uniformly.

688
pnpm-lock.yaml generated
View File

@@ -508,9 +508,48 @@ importers:
packages/core-shared:
dependencies:
'@opentelemetry/api':
specifier: ^1.9.0
version: 1.9.1
'@opentelemetry/api-logs':
specifier: ^0.55.0
version: 0.55.0
'@opentelemetry/instrumentation':
specifier: ^0.55.0
version: 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation-http':
specifier: ^0.55.0
version: 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation-pg':
specifier: ^0.50.0
version: 0.50.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation-undici':
specifier: ^0.10.0
version: 0.10.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources':
specifier: ^1.27.0
version: 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs':
specifier: ^0.55.0
version: 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics':
specifier: ^1.27.0
version: 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-node':
specifier: ^0.55.0
version: 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base':
specifier: ^1.27.0
version: 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions':
specifier: ^1.27.0
version: 1.40.0
'@sentry/nextjs':
specifier: ^10.51.0
version: 10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@16.2.2(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2)
version: 10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@16.2.2(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2)
'@sentry/opentelemetry':
specifier: ^10.51.0
version: 10.52.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)
'@trpc/server':
specifier: ^11.0.0
version: 11.16.0(typescript@5.9.3)
@@ -524,6 +563,9 @@ importers:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
'@opentelemetry/context-async-hooks':
specifier: ^1.28.0
version: 1.28.0(@opentelemetry/api@1.9.1)
'@repo/core-eslint':
specifier: workspace:*
version: link:../core-eslint
@@ -1593,6 +1635,15 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@grpc/grpc-js@1.14.3':
resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
engines: {node: '>=12.10.0'}
'@grpc/proto-loader@0.8.1':
resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==}
engines: {node: '>=6'}
hasBin: true
'@hapi/address@5.1.1':
resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
engines: {node: '>=14.0.0'}
@@ -2144,6 +2195,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@js-sdsl/ordered-map@4.4.2':
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
@@ -2354,10 +2408,36 @@ packages:
resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.55.0':
resolution: {integrity: sha512-3cpa+qI45VHYcA5c0bHM6VHo9gicv3p5mlLHNG3rLyjQU8b7e0st1rWtrUn3JbZ3DwwCfhKop4eQ9UuYlC6Pkg==}
engines: {node: '>=14'}
'@opentelemetry/api-logs@0.57.2':
resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==}
engines: {node: '>=14'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@opentelemetry/context-async-hooks@1.28.0':
resolution: {integrity: sha512-igcl4Ve+F1N2063PJUkesk/GkYyuGIWinYkSyAFTnIj3gzrOgvOA4k747XNdL47HRRL1w/qh7UW8NDuxOLvKFA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@1.28.0':
resolution: {integrity: sha512-ZLwRMV+fNDpVmF2WYUdBHlq0eOWtEaUJSusrzjGnBt7iSRvfjFE3RXYUZJrqou/wIDWV0DwQ5KIfYe9WXg9Xqw==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@1.30.1':
resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@2.6.1':
resolution: {integrity: sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -2370,6 +2450,48 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/exporter-logs-otlp-grpc@0.55.0':
resolution: {integrity: sha512-ykqawCL0ILJWyCJlxCPSAlqQXZ6x2bQsxAVUu8S3z22XNqY5SMx0rl2d93XnvnrOwtcfm+sM9ZhbGh/i5AZ9xw==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/exporter-logs-otlp-http@0.55.0':
resolution: {integrity: sha512-fpFObWWq+DoLVrBU2dyMEaVkibByEkmKQZIUIjW/4j7lwIsTgW7aJCoD9RYFVB/tButcqov5Es2C0J2wTjM2tg==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/exporter-logs-otlp-proto@0.55.0':
resolution: {integrity: sha512-vjE+DxUr+cUpxikdKCPiLZM5Wx7g1bywjCG76TQocvsA7Tmbb9p0t1+8gPlu9AGH7VEzPwDxxpN4p1ajpOurzQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/exporter-trace-otlp-grpc@0.55.0':
resolution: {integrity: sha512-ohIkCLn2Wc3vhhFuf1bH8kOXHMEdcWiD847x7f3Qfygc+CGiatGLzQYscTcEYsWGMV22gVwB/kVcNcx5a3o8gA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/exporter-trace-otlp-http@0.55.0':
resolution: {integrity: sha512-lMiNic63EVHpW+eChmLD2CieDmwQBFi72+LFbh8+5hY0ShrDGrsGP/zuT5MRh7M/vM/UZYO/2A/FYd7CMQGR7A==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/exporter-trace-otlp-proto@0.55.0':
resolution: {integrity: sha512-qxiJFP+bBZW3+goHCGkE1ZdW9gJU0fR7eQ6OP+Rz5oGtEBbq4nkGodhb7C9FJlEFlE2siPtCxoeupV0gtYynag==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/exporter-zipkin@1.28.0':
resolution: {integrity: sha512-AMwr3eGXaPEH7gk8yhcUcen31VXy1yU5VJETu0pCfGpggGCYmhm0FKgYBpL5/vlIgQJWU/sW2vIjCL7aSilpKg==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.0.0
'@opentelemetry/instrumentation-amqplib@0.61.0':
resolution: {integrity: sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -2418,6 +2540,12 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-http@0.55.0':
resolution: {integrity: sha512-AO27XSjkgNicfy/YBthskFAwx9VfaO7tChrLaTONTfOWv14GlB3Rs2eTYpywZIHWsW2cR5hvVkcDte4GV0stoA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-ioredis@0.62.0':
resolution: {integrity: sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -2472,6 +2600,12 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-pg@0.50.0':
resolution: {integrity: sha512-TtLxDdYZmBhFswm8UIsrDjh/HFBeDXd4BLmE8h2MxirNHewLJ0VS9UUddKKEverb5Sm2qFVjqRjcU+8Iw4FJ3w==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-pg@0.66.0':
resolution: {integrity: sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -2490,6 +2624,12 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-undici@0.10.1':
resolution: {integrity: sha512-rkOGikPEyRpMCmNu9AQuV5dtRlDmJp2dK5sw8roVshAGoB6hH/3QjDtRhdwd75SsJwgynWUNRUYe0wAkTo16tQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.7.0
'@opentelemetry/instrumentation@0.207.0':
resolution: {integrity: sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -2508,26 +2648,130 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.55.0':
resolution: {integrity: sha512-YDCMlaQRZkziLL3t6TONRgmmGxDx6MyQDXRD0dknkkgUZtOK5+8MWft1OXzmNu6XfBOdT12MKN5rz+jHUkafKQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.57.2':
resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-exporter-base@0.55.0':
resolution: {integrity: sha512-iHQI0Zzq3h1T6xUJTVFwmFl5Dt5y1es+fl4kM+k5T/3YvmVyeYkSiF+wHCg6oKrlUAJfk+t55kaAu3sYmt7ZYA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-grpc-exporter-base@0.55.0':
resolution: {integrity: sha512-gebbjl9FiSp52igWXuGjcWQKfB6IBwFGt5z1VFwTcVZVeEZevB6bJIqoFrhH4A02m7OUlpJ7l4EfRi3UtkNANQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-transformer@0.55.0':
resolution: {integrity: sha512-kVqEfxtp6mSN2Dhpy0REo1ghP4PYhC1kMHQJ2qVlO99Pc+aigELjZDfg7/YKmL71gR6wVGIeJfiql/eXL7sQPA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/propagator-b3@1.28.0':
resolution: {integrity: sha512-Q7HVDIMwhN5RxL4bECMT4BdbyYSAKkC6U/RGn4NpO/cbqP6ZRg+BS7fPo/pGZi2w8AHfpIGQFXQmE8d2PC5xxQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/propagator-jaeger@1.28.0':
resolution: {integrity: sha512-wKJ94+s8467CnIRgoSRh0yXm/te0QMOwTq9J01PfG/RzYZvlvN8aRisN2oZ9SznB45dDGnMj3BhUlchSA9cEKA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/redis-common@0.38.3':
resolution: {integrity: sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==}
engines: {node: ^18.19.0 || >=20.6.0}
'@opentelemetry/resources@1.28.0':
resolution: {integrity: sha512-cIyXSVJjGeTICENN40YSvLDAq4Y2502hGK3iN7tfdynQLKWb3XWZQEkPc+eSx47kiy11YeFAlYkEfXwR1w8kfw==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/resources@1.30.1':
resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/resources@2.7.1':
resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-logs@0.55.0':
resolution: {integrity: sha512-TSx+Yg/d48uWW6HtjS1AD5x6WPfLhDWLl/WxC7I2fMevaiBuKCuraxTB8MDXieCNnBI24bw9ytyXrDCswFfWgA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.4.0 <1.10.0'
'@opentelemetry/sdk-metrics@1.28.0':
resolution: {integrity: sha512-43tqMK/0BcKTyOvm15/WQ3HLr0Vu/ucAl/D84NO7iSlv6O4eOprxSHa3sUtmYkaZWHqdDJV0AHVz/R6u4JALVQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-node@0.55.0':
resolution: {integrity: sha512-gSXQWV23+9vhbjsvAIeM0LxY3W8DTKI3MZlzFp61noIb1jSr46ET+qoUjHlfZ1Yymebv9KXWeZsqhft81HBXuQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-trace-base@1.28.0':
resolution: {integrity: sha512-ceUVWuCpIao7Y5xE02Xs3nQi0tOGmMea17ecBdwtCvdo9ekmO+ijc9RFDgfifMl7XCBf41zne/1POM3LqSTZDA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/sdk-trace-base@1.30.1':
resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/sdk-trace-base@2.7.1':
resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-trace-node@1.28.0':
resolution: {integrity: sha512-N0sYfYXvHpP0FNIyc+UfhLnLSTOuZLytV0qQVrDWIlABeD/DWJIGttS7nYeR14gQLXch0M1DW8zm3VeN6Opwtg==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/semantic-conventions@1.27.0':
resolution: {integrity: sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==}
engines: {node: '>=14'}
'@opentelemetry/semantic-conventions@1.28.0':
resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==}
engines: {node: '>=14'}
'@opentelemetry/semantic-conventions@1.40.0':
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
engines: {node: '>=14'}
'@opentelemetry/sql-common@0.40.1':
resolution: {integrity: sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@opentelemetry/sql-common@0.41.2':
resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -2684,6 +2928,36 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.8
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.5':
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
'@protobufjs/eventemitter@1.1.0':
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
'@protobufjs/fetch@1.1.0':
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/inquire@1.1.1':
resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.1':
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
'@rollup/plugin-commonjs@28.0.1':
resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==}
engines: {node: '>=16.0.0 || 14 >= 14.17'}
@@ -3642,6 +3916,9 @@ packages:
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
'@types/pg-pool@2.0.6':
resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==}
'@types/pg-pool@2.0.7':
resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==}
@@ -3651,6 +3928,9 @@ packages:
'@types/pg@8.15.6':
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
'@types/pg@8.6.1':
resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -3667,6 +3947,9 @@ packages:
'@types/resolve@1.20.6':
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
'@types/shimmer@1.2.0':
resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==}
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
@@ -5164,6 +5447,9 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
import-in-the-middle@1.15.0:
resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==}
import-in-the-middle@2.0.6:
resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==}
@@ -5715,6 +6001,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.flattendeep@4.4.0:
resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==}
@@ -5728,6 +6017,9 @@ packages:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -6361,6 +6653,10 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
protobufjs@7.5.7:
resolution: {integrity: sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA==}
engines: {node: '>=12.0.0'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
@@ -6492,6 +6788,10 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-in-the-middle@7.5.2:
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
engines: {node: '>=8.6.0'}
require-in-the-middle@8.0.1:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
@@ -6645,6 +6945,9 @@ packages:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
shimmer@1.2.1:
resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -8096,6 +8399,18 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
'@grpc/grpc-js@1.14.3':
dependencies:
'@grpc/proto-loader': 0.8.1
'@js-sdsl/ordered-map': 4.4.2
'@grpc/proto-loader@0.8.1':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.5.7
yargs: 17.7.2
'@hapi/address@5.1.1':
dependencies:
'@hapi/hoek': 11.0.7
@@ -8797,6 +9112,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@js-sdsl/ordered-map@4.4.2': {}
'@jsdevtools/ono@7.1.3': {}
'@lexical/clipboard@0.41.0':
@@ -9047,8 +9364,30 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs@0.55.0':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs@0.57.2':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api@1.9.1': {}
'@opentelemetry/context-async-hooks@1.28.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core@1.28.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.27.0
'@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.28.0
'@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -9059,6 +9398,71 @@ snapshots:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/exporter-logs-otlp-grpc@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@grpc/grpc-js': 1.14.3
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-grpc-exporter-base': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-logs-otlp-http@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.55.0
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-logs-otlp-proto@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.55.0
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-grpc@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@grpc/grpc-js': 1.14.3
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-grpc-exporter-base': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-http@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-proto@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-zipkin@1.28.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.27.0
'@opentelemetry/instrumentation-amqplib@0.61.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -9126,6 +9530,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-http@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.27.0
forwarded-parse: 2.1.2
semver: 7.7.4
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-ioredis@0.62.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -9202,6 +9617,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-pg@0.50.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.27.0
'@opentelemetry/sql-common': 0.40.1(@opentelemetry/api@1.9.1)
'@types/pg': 8.6.1
'@types/pg-pool': 2.0.6
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-pg@0.66.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -9232,6 +9659,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-undici@0.10.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.1)
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -9259,14 +9694,134 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.55.0
'@types/shimmer': 1.2.0
import-in-the-middle: 1.15.0
require-in-the-middle: 7.5.2
semver: 7.7.4
shimmer: 1.2.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.57.2
'@types/shimmer': 1.2.0
import-in-the-middle: 1.15.0
require-in-the-middle: 7.5.2
semver: 7.7.4
shimmer: 1.2.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/otlp-exporter-base@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-grpc-exporter-base@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@grpc/grpc-js': 1.14.3
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-transformer@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.55.0
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.28.0(@opentelemetry/api@1.9.1)
protobufjs: 7.5.7
'@opentelemetry/propagator-b3@1.28.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/propagator-jaeger@1.28.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/redis-common@0.38.3': {}
'@opentelemetry/resources@1.28.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.27.0
'@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.28.0
'@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/sdk-logs@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.55.0
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics@1.28.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-node@0.55.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.55.0
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-logs-otlp-grpc': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-logs-otlp-http': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-logs-otlp-proto': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-grpc': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-http': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-trace-otlp-proto': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/exporter-zipkin': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-logs': 0.55.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-metrics': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-node': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.27.0
transitivePeerDependencies:
- supports-color
'@opentelemetry/sdk-trace-base@1.28.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.27.0
'@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.28.0
'@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -9274,8 +9829,27 @@ snapshots:
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/sdk-trace-node@1.28.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/context-async-hooks': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/propagator-b3': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/propagator-jaeger': 1.28.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.28.0(@opentelemetry/api@1.9.1)
semver: 7.7.4
'@opentelemetry/semantic-conventions@1.27.0': {}
'@opentelemetry/semantic-conventions@1.28.0': {}
'@opentelemetry/semantic-conventions@1.40.0': {}
'@opentelemetry/sql-common@0.40.1(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -9678,6 +10252,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.5': {}
'@protobufjs/eventemitter@1.1.0': {}
'@protobufjs/fetch@1.1.0':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/inquire': 1.1.1
'@protobufjs/float@1.0.2': {}
'@protobufjs/inquire@1.1.1': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.1': {}
'@rollup/plugin-commonjs@28.0.1(rollup@4.60.1)':
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
@@ -9888,6 +10485,31 @@ snapshots:
'@sentry/core@10.52.0': {}
'@sentry/nextjs@10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@16.2.2(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.40.0
'@rollup/plugin-commonjs': 28.0.1(rollup@4.60.1)
'@sentry-internal/browser-utils': 10.51.0
'@sentry/bundler-plugin-core': 5.2.1
'@sentry/core': 10.51.0
'@sentry/node': 10.51.0
'@sentry/opentelemetry': 10.51.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)
'@sentry/react': 10.51.0(react@19.2.4)
'@sentry/vercel-edge': 10.51.0
'@sentry/webpack-plugin': 5.2.1(webpack@5.106.2)
next: 16.2.2(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0)
rollup: 4.60.1
stacktrace-parser: 0.1.11
transitivePeerDependencies:
- '@opentelemetry/core'
- '@opentelemetry/exporter-trace-otlp-http'
- '@opentelemetry/sdk-trace-base'
- encoding
- react
- supports-color
- webpack
'@sentry/nextjs@10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -10034,6 +10656,14 @@ snapshots:
- '@opentelemetry/exporter-trace-otlp-http'
- supports-color
'@sentry/opentelemetry@10.51.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@sentry/core': 10.51.0
'@sentry/opentelemetry@10.51.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -10042,6 +10672,14 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.40.0
'@sentry/core': 10.51.0
'@sentry/opentelemetry@10.52.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@sentry/core': 10.52.0
'@sentry/opentelemetry@10.52.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -10724,6 +11362,10 @@ snapshots:
'@types/parse-json@4.0.2': {}
'@types/pg-pool@2.0.6':
dependencies:
'@types/pg': 8.15.6
'@types/pg-pool@2.0.7':
dependencies:
'@types/pg': 8.15.6
@@ -10740,6 +11382,12 @@ snapshots:
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/pg@8.6.1':
dependencies:
'@types/node': 22.19.17
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -10754,6 +11402,8 @@ snapshots:
'@types/resolve@1.20.6': {}
'@types/shimmer@1.2.0': {}
'@types/stack-utils@2.0.3': {}
'@types/tedious@4.0.14':
@@ -12345,6 +12995,13 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
import-in-the-middle@1.15.0:
dependencies:
acorn: 8.16.0
acorn-import-attributes: 1.9.5(acorn@8.16.0)
cjs-module-lexer: 1.4.3
module-details-from-path: 1.0.4
import-in-the-middle@2.0.6:
dependencies:
acorn: 8.16.0
@@ -13102,6 +13759,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
lodash.flattendeep@4.4.0: {}
lodash.merge@4.6.2: {}
@@ -13110,6 +13769,8 @@ snapshots:
loglevel@1.9.2: {}
long@5.3.2: {}
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -13912,6 +14573,21 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
protobufjs@7.5.7:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.5
'@protobufjs/eventemitter': 1.1.0
'@protobufjs/fetch': 1.1.0
'@protobufjs/float': 1.0.2
'@protobufjs/inquire': 1.1.1
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.1
'@types/node': 22.19.17
long: 5.3.2
proxy-from-env@1.1.0: {}
proxy-from-env@2.1.0: {}
@@ -14051,6 +14727,14 @@ snapshots:
require-from-string@2.0.2: {}
require-in-the-middle@7.5.2:
dependencies:
debug: 4.4.3
module-details-from-path: 1.0.4
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3
@@ -14266,6 +14950,8 @@ snapshots:
shell-quote@1.8.3: {}
shimmer@1.2.1: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0