Initial commit
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
// packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.test.ts
|
||||
import "reflect-metadata";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Container } from "inversify";
|
||||
import { bindNoopInstrumentation } from "@/instrumentation/di/bind-noop-instrumentation";
|
||||
import { INSTRUMENTATION_SYMBOLS } from "@/instrumentation/symbols";
|
||||
import { NoopTracer } from "@/instrumentation/noop-tracer";
|
||||
import { NoopLogger } from "@/instrumentation/noop-logger";
|
||||
import type { ITracer, ILogger } from "@/instrumentation";
|
||||
|
||||
describe("bindNoopInstrumentation", () => {
|
||||
it("returns a tracer + logger pair", () => {
|
||||
const c = new Container();
|
||||
const { tracer, logger } = bindNoopInstrumentation(c);
|
||||
expect(tracer).toBeInstanceOf(NoopTracer);
|
||||
expect(logger).toBeInstanceOf(NoopLogger);
|
||||
});
|
||||
|
||||
it("binds TRACER and LOGGER symbols on the container", () => {
|
||||
const c = new Container();
|
||||
bindNoopInstrumentation(c);
|
||||
const tracer = c.get<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||
const logger = c.get<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER);
|
||||
expect(tracer).toBeInstanceOf(NoopTracer);
|
||||
expect(logger).toBeInstanceOf(NoopLogger);
|
||||
});
|
||||
|
||||
it("is idempotent — second call rebinds the same instances", () => {
|
||||
const c = new Container();
|
||||
const first = bindNoopInstrumentation(c);
|
||||
const second = bindNoopInstrumentation(c);
|
||||
// Implementations are NoopX, but instances may differ — that's fine
|
||||
expect(c.get(INSTRUMENTATION_SYMBOLS.TRACER)).toBe(second.tracer);
|
||||
expect(c.get(INSTRUMENTATION_SYMBOLS.LOGGER)).toBe(second.logger);
|
||||
expect(first.tracer).toBeInstanceOf(NoopTracer);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// packages/core-shared/src/instrumentation/di/bind-noop-instrumentation.ts
|
||||
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, 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);
|
||||
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 };
|
||||
}
|
||||
35
packages/core-shared/src/instrumentation/index.ts
Normal file
35
packages/core-shared/src/instrumentation/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type {
|
||||
ITracer,
|
||||
ISpan,
|
||||
SpanOpts,
|
||||
AttributeValue,
|
||||
} from "./tracer.interface";
|
||||
export type {
|
||||
ILogger,
|
||||
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 {
|
||||
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";
|
||||
export { currentTraceId } from "./otel/current-trace-id";
|
||||
|
||||
// Re-export brand types alongside the wrappers that attach them, so callers
|
||||
// can `import { withSpan, type Instrumented } from "@repo/core-shared/instrumentation"`.
|
||||
export type { Instrumented, Captured } from "../conformance/brands";
|
||||
23
packages/core-shared/src/instrumentation/logger.interface.ts
Normal file
23
packages/core-shared/src/instrumentation/logger.interface.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type Breadcrumb = {
|
||||
category: string;
|
||||
message: string;
|
||||
level?: "info" | "warning" | "error";
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CaptureContext = {
|
||||
tags?: Record<string, string>;
|
||||
extras?: Record<string, unknown>;
|
||||
fingerprint?: string[];
|
||||
};
|
||||
|
||||
export interface ILogger {
|
||||
captureException(err: unknown, ctx?: CaptureContext): void;
|
||||
captureMessage(
|
||||
msg: string,
|
||||
level?: "info" | "warning" | "error",
|
||||
ctx?: CaptureContext,
|
||||
): void;
|
||||
addBreadcrumb(b: Breadcrumb): void;
|
||||
setUser(user: { id: string } | null): void;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
34
packages/core-shared/src/instrumentation/noop-logger.test.ts
Normal file
34
packages/core-shared/src/instrumentation/noop-logger.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NoopLogger } from "@/instrumentation/noop-logger";
|
||||
|
||||
describe("NoopLogger", () => {
|
||||
it("captureException is callable with err and ctx", () => {
|
||||
const logger = new NoopLogger();
|
||||
expect(() => logger.captureException(new Error("x"))).not.toThrow();
|
||||
expect(() =>
|
||||
logger.captureException(new Error("x"), { tags: { feature: "blog" } }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("captureMessage is callable", () => {
|
||||
const logger = new NoopLogger();
|
||||
expect(() => logger.captureMessage("hello")).not.toThrow();
|
||||
expect(() => logger.captureMessage("hello", "warning")).not.toThrow();
|
||||
expect(() =>
|
||||
logger.captureMessage("hello", "error", { extras: { foo: 1 } }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("addBreadcrumb is callable", () => {
|
||||
const logger = new NoopLogger();
|
||||
expect(() =>
|
||||
logger.addBreadcrumb({ category: "test", message: "x" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("setUser accepts opaque id and null", () => {
|
||||
const logger = new NoopLogger();
|
||||
expect(() => logger.setUser({ id: "u1" })).not.toThrow();
|
||||
expect(() => logger.setUser(null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
12
packages/core-shared/src/instrumentation/noop-logger.ts
Normal file
12
packages/core-shared/src/instrumentation/noop-logger.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ILogger, Breadcrumb, CaptureContext } from "./logger.interface";
|
||||
|
||||
export class NoopLogger implements ILogger {
|
||||
captureException(_err: unknown, _ctx?: CaptureContext): void {}
|
||||
captureMessage(
|
||||
_msg: string,
|
||||
_level?: "info" | "warning" | "error",
|
||||
_ctx?: CaptureContext,
|
||||
): void {}
|
||||
addBreadcrumb(_b: Breadcrumb): void {}
|
||||
setUser(_user: { id: string } | null): 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 {}
|
||||
}
|
||||
41
packages/core-shared/src/instrumentation/noop-tracer.test.ts
Normal file
41
packages/core-shared/src/instrumentation/noop-tracer.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { NoopTracer } from "@/instrumentation/noop-tracer";
|
||||
import type { ISpan } from "@/instrumentation/tracer.interface";
|
||||
|
||||
describe("NoopTracer", () => {
|
||||
it("startSpan returns the function result", async () => {
|
||||
const tracer = new NoopTracer();
|
||||
const result = await tracer.startSpan({ name: "test.op" }, async () => 42);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("startSpan passes a no-op ISpan to the function", async () => {
|
||||
const tracer = new NoopTracer();
|
||||
let received: ISpan | undefined;
|
||||
await tracer.startSpan({ name: "test.op" }, async (span) => {
|
||||
received = span;
|
||||
return undefined;
|
||||
});
|
||||
expect(received).toBeDefined();
|
||||
expect(() => received!.setAttribute("k", "v")).not.toThrow();
|
||||
expect(() => received!.setStatus("ok")).not.toThrow();
|
||||
expect(() => received!.setStatus("error", "msg")).not.toThrow();
|
||||
});
|
||||
|
||||
it("propagates exceptions from the wrapped function", async () => {
|
||||
const tracer = new NoopTracer();
|
||||
const err = new Error("boom");
|
||||
await expect(
|
||||
tracer.startSpan({ name: "test.op" }, async () => {
|
||||
throw err;
|
||||
}),
|
||||
).rejects.toBe(err);
|
||||
});
|
||||
|
||||
it("does not invoke external services", async () => {
|
||||
const tracer = new NoopTracer();
|
||||
const fn = vi.fn(async () => "ok");
|
||||
await tracer.startSpan({ name: "test.op" }, fn);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
12
packages/core-shared/src/instrumentation/noop-tracer.ts
Normal file
12
packages/core-shared/src/instrumentation/noop-tracer.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ITracer, ISpan, SpanOpts } from "./tracer.interface";
|
||||
|
||||
const NOOP_SPAN: ISpan = {
|
||||
setAttribute: () => {},
|
||||
setStatus: () => {},
|
||||
};
|
||||
|
||||
export class NoopTracer implements ITracer {
|
||||
async startSpan<T>(_opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
|
||||
return fn(NOOP_SPAN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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 { currentTraceId } from "./current-trace-id";
|
||||
|
||||
// Register async context manager so startActiveSpan propagates context.
|
||||
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("currentTraceId", () => {
|
||||
let exporter: InMemorySpanExporter;
|
||||
let provider: BasicTracerProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
({ exporter, provider } = setupProvider());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.shutdown();
|
||||
trace.disable();
|
||||
});
|
||||
|
||||
it("returns undefined when no active span", () => {
|
||||
expect(currentTraceId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the active span's traceId when inside startActiveSpan", async () => {
|
||||
const tracer = trace.getTracer("test");
|
||||
await new Promise<void>((resolve) => {
|
||||
tracer.startActiveSpan("test-span", (span) => {
|
||||
const id = currentTraceId();
|
||||
expect(id).toBeDefined();
|
||||
expect(id).toMatch(/^[a-f0-9]{32}$/);
|
||||
span.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("filters all-zeros invalid traceId", () => {
|
||||
// The INVALID_SPAN (no-op) has traceId "00000000000000000000000000000000"
|
||||
// which is what getActiveSpan() returns when there is no real span.
|
||||
// currentTraceId() must treat this as absent.
|
||||
expect(currentTraceId()).toBeUndefined();
|
||||
});
|
||||
|
||||
// Suppress unused variable warning — exporter used via closure
|
||||
it("returns distinct traceIds for independent spans", async () => {
|
||||
const tracer = trace.getTracer("test");
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await new Promise<void>((resolve) => {
|
||||
tracer.startActiveSpan(`span-${i}`, (span) => {
|
||||
const id = currentTraceId();
|
||||
if (id) ids.push(id);
|
||||
span.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
// Both spans are in fresh traces — IDs should be valid hex strings
|
||||
expect(ids).toHaveLength(2);
|
||||
for (const id of ids) {
|
||||
expect(id).toMatch(/^[a-f0-9]{32}$/);
|
||||
}
|
||||
void exporter; // suppress lint
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { trace } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Returns the trace ID of the currently active OTel span, or undefined if
|
||||
* there is no active span (e.g., outside any request context, in unit tests
|
||||
* without an OTel SDK).
|
||||
*
|
||||
* Used by core-audit's TraceIdEnrichingAuditLog decorator to auto-populate
|
||||
* AuditEntry.correlationId so callers don't have to thread it explicitly.
|
||||
*
|
||||
* Returns undefined for the all-zeros invalid trace ID — OTel emits this
|
||||
* when context propagation hasn't kicked in.
|
||||
*/
|
||||
export function currentTraceId(): string | undefined {
|
||||
const span = trace.getActiveSpan();
|
||||
if (!span) return undefined;
|
||||
const ctx = span.spanContext();
|
||||
if (!ctx.traceId || /^0+$/.test(ctx.traceId)) return undefined;
|
||||
return ctx.traceId;
|
||||
}
|
||||
3
packages/core-shared/src/instrumentation/otel/index.ts
Normal file
3
packages/core-shared/src/instrumentation/otel/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { initOtelServerNode, type InitOtelServerNodeOpts } from "./init-server-node";
|
||||
export { buildResource, type BuildResourceOpts } from "./resource";
|
||||
export { currentTraceId } from "./current-trace-id";
|
||||
@@ -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,98 @@
|
||||
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 revision 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 — chosen 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. 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
|
||||
|
||||
// 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;
|
||||
|
||||
// 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,184 @@
|
||||
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
|
||||
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", () => {
|
||||
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", () => {
|
||||
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,126 @@
|
||||
// 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 — 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.
|
||||
*/
|
||||
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).
|
||||
* 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).
|
||||
* Attribute-key-based PII redaction; 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.
|
||||
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,152 @@
|
||||
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();
|
||||
// logRecordProcessor is 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();
|
||||
});
|
||||
});
|
||||
148
packages/core-shared/src/instrumentation/otel/sentry-bridge.ts
Normal file
148
packages/core-shared/src/instrumentation/otel/sentry-bridge.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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 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;
|
||||
}
|
||||
24
packages/core-shared/src/instrumentation/reported-flag.ts
Normal file
24
packages/core-shared/src/instrumentation/reported-flag.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Non-enumerable flag used by every ILogger implementation to skip
|
||||
// already-reported errors. The flag is non-enumerable so JSON.stringify
|
||||
// and {...err} spread won't surface it.
|
||||
|
||||
const REPORTED = "__sentryReported" as const;
|
||||
|
||||
export function isReported(err: unknown): boolean {
|
||||
return (
|
||||
err !== null &&
|
||||
typeof err === "object" &&
|
||||
Boolean((err as Record<string, unknown>)[REPORTED])
|
||||
);
|
||||
}
|
||||
|
||||
export function markReported(err: unknown): void {
|
||||
if (err !== null && typeof err === "object" && !isReported(err)) {
|
||||
Object.defineProperty(err, REPORTED, {
|
||||
value: true,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/init-client-react.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { replayIntegration, feedbackIntegration } = vi.hoisted(() => {
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Replay",
|
||||
_opts: opts,
|
||||
}));
|
||||
const feedbackIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Feedback",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration, feedbackIntegration };
|
||||
});
|
||||
|
||||
vi.mock("@sentry/react", () => ({
|
||||
init: vi.fn(),
|
||||
replayIntegration,
|
||||
feedbackIntegration,
|
||||
}));
|
||||
|
||||
import * as SentryReact from "@sentry/react";
|
||||
import { initSentryClientReact } from "@/instrumentation/sentry/init-client-react";
|
||||
|
||||
describe("initSentryClientReact", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls SentryReact.init with sendDefaultPii: false", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["sendDefaultPii"]).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches replay integration with mask flags", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(replayOpts["maskAllText"]).toBe(true);
|
||||
expect(replayOpts["maskAllInputs"]).toBe(true);
|
||||
expect(replayOpts["blockAllMedia"]).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults replay sample rates", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["replaysSessionSampleRate"]).toBe(0.0);
|
||||
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
|
||||
});
|
||||
|
||||
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryReact.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("is a no-op when dsn is missing", () => {
|
||||
initSentryClientReact({ dsn: "", app: "web-tanstack" });
|
||||
expect(SentryReact.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches feedbackIntegration when SentryReact.feedbackIntegration is available", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
expect(feedbackIntegration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => {
|
||||
initSentryClientReact({
|
||||
dsn: "https://x@y/1",
|
||||
app: "web-tanstack",
|
||||
nonce: "abc123",
|
||||
});
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBe("abc123");
|
||||
expect(feedbackOpts["scriptNonce"]).toBe("abc123");
|
||||
});
|
||||
|
||||
it("omits nonce props from feedbackIntegration when nonce not provided", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBeUndefined();
|
||||
expect(feedbackOpts["scriptNonce"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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 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";
|
||||
|
||||
// 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).
|
||||
* Mirrors init-client.ts but uses @sentry/react directly. Same PII,
|
||||
* replay, and scrubbing requirements apply.
|
||||
*/
|
||||
export function initSentryClientReact(opts: InitClientOpts): void {
|
||||
if (!opts.dsn) return;
|
||||
|
||||
const isProd = process.env["NODE_ENV"] === "production";
|
||||
const { nonce } = opts;
|
||||
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["NODE_ENV"] ??
|
||||
"development";
|
||||
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,
|
||||
beforeSend: ((event: SentryEvent) =>
|
||||
deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"],
|
||||
beforeSendTransaction: ((event: SentryEvent) => {
|
||||
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,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [
|
||||
// mandatory mask flags; allowlist starts empty
|
||||
SentryReact.replayIntegration({
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
...(SentryReact.feedbackIntegration
|
||||
? [
|
||||
SentryReact.feedbackIntegration({
|
||||
...(nonce ? { styleNonce: nonce, scriptNonce: nonce } : {}),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
initialScope: { tags: { app: opts.app } },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/init-client.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { replayIntegration, feedbackIntegration } = vi.hoisted(() => {
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Replay",
|
||||
_opts: opts,
|
||||
}));
|
||||
const feedbackIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Feedback",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration, feedbackIntegration };
|
||||
});
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
init: vi.fn(),
|
||||
replayIntegration,
|
||||
feedbackIntegration,
|
||||
}));
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { initSentryClient } from "@/instrumentation/sentry/init-client";
|
||||
|
||||
describe("initSentryClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls Sentry.init with sendDefaultPii: false", () => {
|
||||
initSentryClient({ 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["sendDefaultPii"]).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(replayOpts["maskAllText"]).toBe(true);
|
||||
expect(replayOpts["maskAllInputs"]).toBe(true);
|
||||
expect(replayOpts["blockAllMedia"]).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults replaysSessionSampleRate to 0.0", () => {
|
||||
initSentryClient({ 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["replaysSessionSampleRate"]).toBe(0.0);
|
||||
});
|
||||
|
||||
it("defaults replaysOnErrorSampleRate to 1.0", () => {
|
||||
initSentryClient({ 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["replaysOnErrorSampleRate"]).toBe(1.0);
|
||||
});
|
||||
|
||||
it("attaches beforeSend + beforeSendTransaction", () => {
|
||||
initSentryClient({ 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("is a no-op when dsn is empty", () => {
|
||||
initSentryClient({ dsn: "", app: "web-next" });
|
||||
expect(Sentry.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches feedbackIntegration when Sentry.feedbackIntegration is available", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
expect(feedbackIntegration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => {
|
||||
initSentryClient({
|
||||
dsn: "https://x@y/1",
|
||||
app: "web-next",
|
||||
nonce: "abc123",
|
||||
});
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBe("abc123");
|
||||
expect(feedbackOpts["scriptNonce"]).toBe("abc123");
|
||||
});
|
||||
|
||||
it("omits nonce props from feedbackIntegration when nonce not provided", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBeUndefined();
|
||||
expect(feedbackOpts["scriptNonce"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
142
packages/core-shared/src/instrumentation/sentry/init-client.ts
Normal file
142
packages/core-shared/src/instrumentation/sentry/init-client.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// 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 {
|
||||
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;
|
||||
app: "web-next" | "cms" | "web-tanstack";
|
||||
release?: string;
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
// 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;
|
||||
const { nonce } = opts;
|
||||
|
||||
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["NODE_ENV"] ??
|
||||
"development";
|
||||
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,
|
||||
beforeSend: ((event: SentryEvent) =>
|
||||
deepScrub(event)) as unknown as InitOpts["beforeSend"],
|
||||
beforeSendTransaction: ((event: SentryEvent) => {
|
||||
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, // privacy default
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [
|
||||
// mandatory mask flags; allowlist starts empty
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
...(Sentry.feedbackIntegration
|
||||
? [
|
||||
Sentry.feedbackIntegration({
|
||||
...(nonce ? { styleNonce: nonce, scriptNonce: nonce } : {}),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
initialScope: { tags: { app: opts.app } },
|
||||
});
|
||||
}
|
||||
5
packages/core-shared/src/instrumentation/symbols.ts
Normal file
5
packages/core-shared/src/instrumentation/symbols.ts
Normal file
@@ -0,0 +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;
|
||||
16
packages/core-shared/src/instrumentation/tracer.interface.ts
Normal file
16
packages/core-shared/src/instrumentation/tracer.interface.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type AttributeValue = string | number | boolean | null;
|
||||
|
||||
export type SpanOpts = {
|
||||
name: string;
|
||||
op?: "use-case" | "controller" | "repository" | "service" | string;
|
||||
attributes?: Record<string, AttributeValue>;
|
||||
};
|
||||
|
||||
export interface ISpan {
|
||||
setAttribute(key: string, value: AttributeValue): void;
|
||||
setStatus(status: "ok" | "error", message?: string): void;
|
||||
}
|
||||
|
||||
export interface ITracer {
|
||||
startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, expectTypeOf, vi } from "vitest";
|
||||
import { withCapture } from "@/instrumentation/with-capture";
|
||||
import type { ILogger } from "@/instrumentation/logger.interface";
|
||||
import { isReported } from "@/instrumentation/reported-flag";
|
||||
import type { Captured } from "@/conformance/brands";
|
||||
import { isCaptured } from "@/conformance/brand-runtime";
|
||||
|
||||
function makeLogger(): ILogger & { captureException: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
captureException: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
addBreadcrumb: vi.fn(),
|
||||
setUser: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("withCapture", () => {
|
||||
it("does not capture on success", async () => {
|
||||
const logger = makeLogger();
|
||||
const wrapped = withCapture(logger, { layer: "use-case" }, async (x: number) => x + 1);
|
||||
await expect(wrapped(1)).resolves.toBe(2);
|
||||
expect(logger.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("captures with tags and re-throws on failure", async () => {
|
||||
const logger = makeLogger();
|
||||
const err = new Error("boom");
|
||||
const wrapped = withCapture(logger, { layer: "use-case", name: "blog.x" }, async () => {
|
||||
throw err;
|
||||
});
|
||||
await expect(wrapped()).rejects.toBe(err);
|
||||
expect(logger.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(logger.captureException).toHaveBeenCalledWith(err, {
|
||||
tags: { layer: "use-case", name: "blog.x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the error as reported after first capture", async () => {
|
||||
const logger = makeLogger();
|
||||
const err = new Error("boom");
|
||||
const wrapped = withCapture(logger, { layer: "use-case" }, async () => {
|
||||
throw err;
|
||||
});
|
||||
await expect(wrapped()).rejects.toBe(err);
|
||||
expect(isReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT capture again when the same error already carries the flag", async () => {
|
||||
const logger = makeLogger();
|
||||
const err = new Error("boom");
|
||||
// Simulate an inner layer (repo) having already captured + marked.
|
||||
const inner = withCapture(logger, { layer: "repo" }, async () => {
|
||||
throw err;
|
||||
});
|
||||
const outer = withCapture(logger, { layer: "use-case" }, () => inner());
|
||||
|
||||
await expect(outer()).rejects.toBe(err);
|
||||
// Only the inner layer captured it; outer saw the flag and bailed.
|
||||
expect(logger.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(logger.captureException).toHaveBeenCalledWith(err, {
|
||||
tags: { layer: "repo" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("withCapture — brand", () => {
|
||||
it("returns a Captured<F>", () => {
|
||||
const logger = makeLogger();
|
||||
const fn = async (a: number) => a + 1;
|
||||
const wrapped = withCapture(logger, { layer: "use-case" }, fn);
|
||||
expectTypeOf(wrapped).toMatchTypeOf<Captured<typeof fn>>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withCapture — runtime brand", () => {
|
||||
it("attaches __captured as a non-enumerable property on the wrapped function", async () => {
|
||||
const logger = makeLogger();
|
||||
const fn = async (a: number) => a + 1;
|
||||
const wrapped = withCapture(logger, { layer: "use-case" }, fn);
|
||||
expect(isCaptured(wrapped)).toBe(true);
|
||||
expect(Object.keys(wrapped)).not.toContain("__captured");
|
||||
});
|
||||
});
|
||||
60
packages/core-shared/src/instrumentation/with-capture.ts
Normal file
60
packages/core-shared/src/instrumentation/with-capture.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { ILogger } from "./logger.interface";
|
||||
import type { Captured } from "../conformance/brands";
|
||||
import { attachBrand } from "../conformance/brand-runtime";
|
||||
import { isReported, markReported } from "./reported-flag";
|
||||
|
||||
/**
|
||||
* Higher-order wrapper applied at DI bind time. Mirrors `withSpan`: takes a
|
||||
* factory result `(args) => Promise<R>` and returns the same shape, but any
|
||||
* thrown error is captured via `logger.captureException(err, { tags })` before
|
||||
* being re-thrown.
|
||||
*
|
||||
* Skips capture if the error already carries the `__sentryReported` flag —
|
||||
* this is what prevents double-capture when the same error bubbles through
|
||||
* a wrapped repo → use case → controller chain (the repo's catch site
|
||||
* captures first; outer wrappers see the flag and bail).
|
||||
*
|
||||
* Usage at bind time:
|
||||
*
|
||||
* const captured = withCapture(logger, { feature: "blog", layer: "use-case", name: "blog.getArticles" }, factory(deps));
|
||||
* const wrapped = withSpan(tracer, opts, captured);
|
||||
*
|
||||
* Span wraps capture: the span timing reflects the captured-and-rethrown
|
||||
* failure (errored span gets a duration), and the capture has accurate
|
||||
* tags by the time it fires.
|
||||
*/
|
||||
export function withCapture<Args extends unknown[], R>(
|
||||
logger: ILogger,
|
||||
tags: Record<string, string>,
|
||||
fn: (...args: Args) => Promise<R>,
|
||||
): Captured<(...args: Args) => Promise<R>> {
|
||||
const PROPAGATED_BRANDS = [
|
||||
"__instrumented",
|
||||
"__audited",
|
||||
"__analyzed",
|
||||
"__consentChecked",
|
||||
"__rateLimited",
|
||||
] as const;
|
||||
|
||||
const wrapped: (...args: Args) => Promise<R> = async (...args) => {
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (err) {
|
||||
if (!isReported(err)) {
|
||||
logger.captureException(err, { tags });
|
||||
markReported(err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
// Propagate brands from the inner function (e.g. __audited from withAudit,
|
||||
// __instrumented if already spanned) so the outermost binding carries all brands.
|
||||
// __captured is omitted here because it is attached explicitly below.
|
||||
for (const brand of PROPAGATED_BRANDS) {
|
||||
if ((fn as unknown as Record<string, unknown>)[brand] === true) {
|
||||
attachBrand(wrapped, brand);
|
||||
}
|
||||
}
|
||||
attachBrand(wrapped, "__captured");
|
||||
return wrapped as Captured<(...args: Args) => Promise<R>>;
|
||||
}
|
||||
83
packages/core-shared/src/instrumentation/with-span.test.ts
Normal file
83
packages/core-shared/src/instrumentation/with-span.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, expectTypeOf, vi } from "vitest";
|
||||
import { withSpan } from "@/instrumentation/with-span";
|
||||
import type { ITracer, ISpan, SpanOpts } from "@/instrumentation/tracer.interface";
|
||||
import type { Instrumented } from "@/conformance/brands";
|
||||
import { isInstrumented } from "@/conformance/brand-runtime";
|
||||
|
||||
function makeRecordingTracer() {
|
||||
const calls: SpanOpts[] = [];
|
||||
const tracer: ITracer = {
|
||||
startSpan: vi.fn(async (opts, fn) => {
|
||||
calls.push(opts);
|
||||
const span: ISpan = { setAttribute: () => {}, setStatus: () => {} };
|
||||
return fn(span);
|
||||
}),
|
||||
};
|
||||
return { tracer, calls };
|
||||
}
|
||||
|
||||
describe("withSpan", () => {
|
||||
it("wraps fn with a span using static opts", async () => {
|
||||
const { tracer, calls } = makeRecordingTracer();
|
||||
const fn = async (a: number, b: number) => a + b;
|
||||
const wrapped = withSpan(tracer, { name: "test.add", op: "use-case" }, fn);
|
||||
const result = await wrapped(2, 3);
|
||||
expect(result).toBe(5);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toEqual({ name: "test.add", op: "use-case" });
|
||||
});
|
||||
|
||||
it("wraps fn with span opts derived from args (function form)", async () => {
|
||||
const { tracer, calls } = makeRecordingTracer();
|
||||
const fn = async (id: string) => `result-${id}`;
|
||||
const wrapped = withSpan(
|
||||
tracer,
|
||||
([id]) => ({ name: "test.byId", op: "repository", attributes: { id } }),
|
||||
fn,
|
||||
);
|
||||
const result = await wrapped("abc");
|
||||
expect(result).toBe("result-abc");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toEqual({
|
||||
name: "test.byId",
|
||||
op: "repository",
|
||||
attributes: { id: "abc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates errors thrown by fn", async () => {
|
||||
const { tracer } = makeRecordingTracer();
|
||||
const wrapped = withSpan(tracer, { name: "test.err" }, async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
await expect(wrapped()).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("preserves identity across multiple invocations (closure stable)", async () => {
|
||||
const { tracer, calls } = makeRecordingTracer();
|
||||
const wrapped = withSpan(tracer, { name: "test.same" }, async (n: number) => n);
|
||||
await wrapped(1);
|
||||
await wrapped(2);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls.every((c) => c.name === "test.same")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withSpan — brand", () => {
|
||||
it("returns an Instrumented<F>", () => {
|
||||
const { tracer } = makeRecordingTracer();
|
||||
const fn = async (a: number) => a + 1;
|
||||
const wrapped = withSpan(tracer, { name: "test.brand", op: "use-case" }, fn);
|
||||
expectTypeOf(wrapped).toMatchTypeOf<Instrumented<typeof fn>>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withSpan — runtime brand", () => {
|
||||
it("attaches __instrumented as a non-enumerable property on the wrapped function", async () => {
|
||||
const { tracer } = makeRecordingTracer();
|
||||
const fn = async (a: number) => a + 1;
|
||||
const wrapped = withSpan(tracer, { name: "test.brand", op: "use-case" }, fn);
|
||||
expect(isInstrumented(wrapped)).toBe(true);
|
||||
expect(Object.keys(wrapped)).not.toContain("__instrumented");
|
||||
});
|
||||
});
|
||||
45
packages/core-shared/src/instrumentation/with-span.ts
Normal file
45
packages/core-shared/src/instrumentation/with-span.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ITracer, SpanOpts } from "./tracer.interface";
|
||||
import type { Instrumented } from "../conformance/brands";
|
||||
import { attachBrand } from "../conformance/brand-runtime";
|
||||
|
||||
const PROPAGATED_BRANDS = [
|
||||
"__captured",
|
||||
"__audited",
|
||||
"__analyzed",
|
||||
"__consentChecked",
|
||||
"__rateLimited",
|
||||
] as const;
|
||||
|
||||
export function withSpan<Args extends unknown[], R, Extra extends object>(
|
||||
tracer: ITracer,
|
||||
opts: SpanOpts | ((args: Args) => SpanOpts),
|
||||
fn: ((...args: Args) => Promise<R>) & Extra,
|
||||
): Instrumented<((...args: Args) => Promise<R>) & Extra>;
|
||||
export function withSpan<Args extends unknown[], R>(
|
||||
tracer: ITracer,
|
||||
opts: SpanOpts | ((args: Args) => SpanOpts),
|
||||
fn: (...args: Args) => Promise<R>,
|
||||
): Instrumented<(...args: Args) => Promise<R>>;
|
||||
export function withSpan<Args extends unknown[], R>(
|
||||
tracer: ITracer,
|
||||
opts: SpanOpts | ((args: Args) => SpanOpts),
|
||||
fn: (...args: Args) => Promise<R>,
|
||||
): Instrumented<(...args: Args) => Promise<R>> {
|
||||
const wrapped: (...args: Args) => Promise<R> = (...args) => {
|
||||
const resolved = typeof opts === "function" ? opts(args) : opts;
|
||||
return tracer.startSpan(resolved, () => fn(...args));
|
||||
};
|
||||
attachBrand(wrapped, "__instrumented");
|
||||
// Propagate brands from the inner function (e.g. __captured from withCapture,
|
||||
// __audited from withAudit) so the outermost binding carries all brands.
|
||||
// withSpan is always outermost — the assertFeatureConformance check reads the
|
||||
// container-resolved value (the withSpan result), so brands must be visible here.
|
||||
for (const brand of PROPAGATED_BRANDS) {
|
||||
if ((fn as unknown as Record<string, unknown>)[brand] === true) {
|
||||
attachBrand(wrapped, brand);
|
||||
}
|
||||
}
|
||||
// Cast is the type-level concession — the brand is now also a non-enumerable
|
||||
// runtime property attached above by `attachBrand`.
|
||||
return wrapped as Instrumented<(...args: Args) => Promise<R>>;
|
||||
}
|
||||
Reference in New Issue
Block a user