Initial commit

This commit is contained in:
fraqtal
2026-07-12 08:15:46 +00:00
commit ee0fec0691
1397 changed files with 127242 additions and 0 deletions

View File

@@ -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
});
});

View File

@@ -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;
}

View 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";

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { initOtelServerNode } from "./init-server-node";
describe("initOtelServerNode", () => {
it("returns an SDK handle with shutdown()", () => {
const sdk = initOtelServerNode({
dsn: "",
serviceName: "test-service",
environment: "test",
});
expect(sdk).toBeDefined();
expect(typeof sdk.shutdown).toBe("function");
});
it("accepts a DSN and wires the Sentry bridge", () => {
const sdk = initOtelServerNode({
dsn: "https://test@sentry.io/1",
serviceName: "test-service",
environment: "test",
});
expect(sdk).toBeDefined();
});
});

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
// packages/core-shared/src/instrumentation/otel/otel-logger.ts
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
import { trace } from "@opentelemetry/api";
import { isReported, markReported } from "../reported-flag";
import type { ILogger, Breadcrumb, CaptureContext } from "../logger.interface";
export class OtelLogger implements ILogger {
private readonly logger = logs.getLogger("@repo/core-shared", "1.0.0");
captureException(err: unknown, ctx?: CaptureContext): void {
if (isReported(err)) return;
markReported(err);
const error = err instanceof Error ? err : new Error(String(err));
this.logger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: "ERROR",
body: error.message,
attributes: {
"exception.type": error.name,
"exception.message": error.message,
"exception.stacktrace": error.stack ?? "",
...flattenTags(ctx?.tags),
...flattenExtras(ctx?.extras),
...(ctx?.fingerprint ? { "sentry.fingerprint": ctx.fingerprint.join("|") } : {}),
},
});
}
captureMessage(msg: string, level?: "info" | "warning" | "error", ctx?: CaptureContext): void {
const severityNumber =
level === "error"
? SeverityNumber.ERROR
: level === "warning"
? SeverityNumber.WARN
: SeverityNumber.INFO;
const severityText =
level === "error" ? "ERROR" : level === "warning" ? "WARNING" : "INFO";
this.logger.emit({
severityNumber,
severityText,
body: msg,
attributes: { ...flattenTags(ctx?.tags), ...flattenExtras(ctx?.extras) },
});
}
addBreadcrumb(b: Breadcrumb): void {
const span = trace.getActiveSpan();
if (!span) return;
span.addEvent(b.message, {
"breadcrumb.category": b.category,
"breadcrumb.level": b.level ?? "info",
...(b.data ? flattenExtras(b.data) : {}),
});
}
setUser(user: { id: string } | null): void {
const span = trace.getActiveSpan();
if (!span) return;
span.setAttribute("user.id", user?.id ?? "");
}
}
function flattenTags(tags?: Record<string, string>): Record<string, string> {
if (!tags) return {};
return Object.fromEntries(Object.entries(tags).map(([k, v]) => [`tag.${k}`, v]));
}
function flattenExtras(extras?: Record<string, unknown>): Record<string, string> {
if (!extras) return {};
return Object.fromEntries(
Object.entries(extras).map(([k, v]) => [
`extra.${k}`,
typeof v === "string" ? v : String(v),
]),
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import type { ITracer, ISpan, SpanOpts } from "../tracer.interface";
export class OtelTracer implements ITracer {
private readonly tracer = trace.getTracer("@repo/core-shared", "1.0.0");
async startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
const attributes: Record<string, string | number | boolean> = {
...(opts.attributes
? (Object.fromEntries(
Object.entries(opts.attributes).filter(([, v]) => v !== null),
) as Record<string, string | number | boolean>)
: {}),
...(opts.op ? { "span.op": opts.op } : {}),
};
return this.tracer.startActiveSpan(
opts.name,
{ kind: SpanKind.INTERNAL, attributes },
async (otelSpan) => {
const adapter: ISpan = {
setAttribute(key, value) {
if (value !== null) {
otelSpan.setAttribute(key, value as string | number | boolean);
}
},
setStatus(status, message) {
otelSpan.setStatus({
code: status === "ok" ? SpanStatusCode.OK : SpanStatusCode.ERROR,
message,
});
},
};
try {
return await fn(adapter);
} catch (err) {
otelSpan.recordException(err as Error);
otelSpan.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
otelSpan.end();
}
},
);
}
}

View File

@@ -0,0 +1,67 @@
// packages/core-shared/src/instrumentation/otel/pii-fields.ts
// 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));
}

View File

@@ -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]");
});
});

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { buildResource } from "./resource";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
} from "@opentelemetry/semantic-conventions/incubating";
describe("buildResource", () => {
it("populates service name, version, and environment", () => {
const r = buildResource({
serviceName: "web-next",
serviceVersion: "1.0.0",
environment: "production",
});
expect(r.attributes[ATTR_SERVICE_NAME]).toBe("web-next");
expect(r.attributes[ATTR_SERVICE_VERSION]).toBe("1.0.0");
expect(r.attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]).toBe("production");
});
it("populates namespace when provided", () => {
const r = buildResource({
serviceName: "web-next",
environment: "production",
namespace: "template-vertical",
});
expect(r.attributes["service.namespace"]).toBe("template-vertical");
});
it("omits version and namespace when not provided", () => {
const r = buildResource({
serviceName: "web-next",
environment: "production",
});
expect(r.attributes[ATTR_SERVICE_VERSION]).toBeUndefined();
expect(r.attributes["service.namespace"]).toBeUndefined();
});
});

View File

@@ -0,0 +1,22 @@
import { Resource } from "@opentelemetry/resources";
export type BuildResourceOpts = {
serviceName: string;
serviceVersion?: string;
environment: string;
namespace?: string;
};
/**
* Builds an OpenTelemetry Resource with semantic-convention attributes.
* Each app constructs its own resource at startup (per-app service name).
*/
export function buildResource(opts: BuildResourceOpts): Resource {
const attrs: Record<string, string> = {
"service.name": opts.serviceName,
"deployment.environment.name": opts.environment,
};
if (opts.serviceVersion) attrs["service.version"] = opts.serviceVersion;
if (opts.namespace) attrs["service.namespace"] = opts.namespace;
return new Resource(attrs);
}

View File

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

View 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;
}