Merge branch 'worktree-opentelemetry-migration': OpenTelemetry migration (ADR-017)
This commit is contained in:
28
AGENTS.md
28
AGENTS.md
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 (R31–R38, 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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` (R31–R55).
|
||||
**Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31–R55 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# ADR-014 — Instrumentation & Sentry Logging
|
||||
|
||||
**Status:** Accepted
|
||||
**Status (revised):** Superseded by ADR-017 for the implementation layer. The interface decisions (R31–R51) 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
|
||||
|
||||
53
docs/decisions/adr-017-opentelemetry-migration.md
Normal file
53
docs/decisions/adr-017-opentelemetry-migration.md
Normal 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 (R31–R51) have held up; what coupled to a vendor was the **substrate**: `SentryTracer` and `SentryLogger` called Sentry SDK methods directly. Swapping vendors required rewriting every `*Tracer`/`*Logger` pair.
|
||||
|
||||
This ADR migrates the substrate to OpenTelemetry: code emits OTel spans, logs, and metrics; exporters route to one or more backends. Sentry is wired as the (initially only) exporter via `@sentry/opentelemetry`. Swapping vendors becomes an exporter swap.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **OTel SDK as substrate.** Server-side `ITracer` and `ILogger` impls use `@opentelemetry/api` and `@opentelemetry/api-logs` respectively. New `IMetrics` signal added via OTel metrics API.
|
||||
2. **Sentry-as-exporter.** `@sentry/opentelemetry` provides `SentrySpanProcessor` + `SentryLogRecordProcessor`. They consume OTel signals and forward to Sentry. Sentry's UI experience is preserved (minus some browser-side richness, addressed below).
|
||||
3. **Server-only scope.** Browser keeps Sentry SDK directly. Replay + session-error correlation stay native. Future spec extends OTel to browser when warranted.
|
||||
4. **Pure OTel Logs API for the logger.** `OtelLogger` emits via `@opentelemetry/api-logs`. Trade-off: slightly degraded Sentry-native error UX (stack normalization, breadcrumb buffer) in exchange for swap-by-exporter vendor neutrality.
|
||||
5. **Breadcrumbs → span events.** `ILogger.addBreadcrumb` attaches to the active OTel span as an event. Native OTel pattern.
|
||||
6. **`setUser` per-span.** Sets `user.id` as a span attribute on the active span. R36 preserved (id only; no email/username).
|
||||
7. **PII scrubbing migrated.** From Sentry's `beforeSend`/`beforeSendTransaction` hooks to OTel `SpanProcessor` + `LogRecordProcessor` impls (`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 (R31–R51) remain authoritative. This ADR supersedes only the implementation section (Sentry SDK direct calls → OTel SDK). ADR-014 keeps a "Status: Superseded for impl by ADR-017" header.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
21
packages/core-shared/src/instrumentation/noop-metrics.ts
Normal file
21
packages/core-shared/src/instrumentation/noop-metrics.ts
Normal 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 {}
|
||||
}
|
||||
2
packages/core-shared/src/instrumentation/otel/index.ts
Normal file
2
packages/core-shared/src/instrumentation/otel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { initOtelServerNode, type InitOtelServerNodeOpts } from "./init-server-node";
|
||||
export { buildResource, type BuildResourceOpts } from "./resource";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
76
packages/core-shared/src/instrumentation/otel/otel-logger.ts
Normal file
76
packages/core-shared/src/instrumentation/otel/otel-logger.ts
Normal 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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
46
packages/core-shared/src/instrumentation/otel/otel-tracer.ts
Normal file
46
packages/core-shared/src/instrumentation/otel/otel-tracer.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
67
packages/core-shared/src/instrumentation/otel/pii-fields.ts
Normal file
67
packages/core-shared/src/instrumentation/otel/pii-fields.ts
Normal 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));
|
||||
}
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
22
packages/core-shared/src/instrumentation/otel/resource.ts
Normal file
22
packages/core-shared/src/instrumentation/otel/resource.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
136
packages/core-shared/src/instrumentation/otel/sentry-bridge.ts
Normal file
136
packages/core-shared/src/instrumentation/otel/sentry-bridge.ts
Normal 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;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
}));
|
||||
@@ -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
688
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user