feat(core-shared): SentryLogRecordForwarder + wire OtelLogger in OTel pipeline
Adds SentryLogRecordForwarder (LogRecordProcessor impl) to sentry-bridge.ts that forwards OTel log records to Sentry via captureException/captureMessage. Wires it as a BatchLogRecordProcessor in init-server-node.ts. Replaces SentryLogger with OtelLogger in bind-otel-instrumentation.ts. 7 new bridge tests pass alongside the existing 25 tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts
|
// packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts
|
||||||
import type { Container } from "inversify";
|
import type { Container } from "inversify";
|
||||||
import { OtelTracer } from "../otel/otel-tracer";
|
import { OtelTracer } from "../otel/otel-tracer";
|
||||||
import { SentryLogger } from "../sentry/sentry-logger";
|
import { OtelLogger } from "../otel/otel-logger";
|
||||||
import { initOtelServerNode } from "../otel/init-server-node";
|
import { initOtelServerNode } from "../otel/init-server-node";
|
||||||
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
|
import { INSTRUMENTATION_SYMBOLS } from "../symbols";
|
||||||
import type { ITracer, ILogger } from "../index";
|
import type { ITracer, ILogger } from "../index";
|
||||||
@@ -31,7 +31,7 @@ export function bindOtelInstrumentation(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tracer = new OtelTracer();
|
const tracer = new OtelTracer();
|
||||||
const logger = new SentryLogger(); // Phase 3 replaces with OtelLogger
|
const logger = new OtelLogger();
|
||||||
|
|
||||||
if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||||
container.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
container.unbind(INSTRUMENTATION_SYMBOLS.TRACER);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
|
import { NodeSDK, tracing, logs as sdkLogs } from "@opentelemetry/sdk-node";
|
||||||
import { buildResource } from "./resource";
|
import { buildResource } from "./resource";
|
||||||
import { createSentryOtelBridge } from "./sentry-bridge";
|
import { createSentryOtelBridge } from "./sentry-bridge";
|
||||||
|
|
||||||
const { BatchSpanProcessor } = tracing;
|
const { BatchSpanProcessor } = tracing;
|
||||||
|
const { BatchLogRecordProcessor } = sdkLogs;
|
||||||
|
|
||||||
export type InitOtelServerNodeOpts = {
|
export type InitOtelServerNodeOpts = {
|
||||||
/** Sentry DSN. When empty, OTel SDK boots without the Sentry exporter. */
|
/** Sentry DSN. When empty, OTel SDK boots without the Sentry exporter. */
|
||||||
@@ -18,7 +19,8 @@ export type InitOtelServerNodeOpts = {
|
|||||||
* Initializes the OpenTelemetry NodeSDK for a server-side app.
|
* Initializes the OpenTelemetry NodeSDK for a server-side app.
|
||||||
* - Configures Resource attributes per OTel semantic conventions.
|
* - Configures Resource attributes per OTel semantic conventions.
|
||||||
* - Registers Sentry span processor (via createSentryOtelBridge) when DSN is set.
|
* - Registers Sentry span processor (via createSentryOtelBridge) when DSN is set.
|
||||||
* - logRecordProcessors filled in Phase 3; metricReader filled in Phase 4.
|
* - Registers Sentry log record processor (Phase 3) when DSN is set.
|
||||||
|
* - metricReader filled in Phase 4.
|
||||||
* - PII scrub processors land in Phase 5.
|
* - PII scrub processors land in Phase 5.
|
||||||
*
|
*
|
||||||
* Caller is responsible for `sdk.shutdown()` on process exit.
|
* Caller is responsible for `sdk.shutdown()` on process exit.
|
||||||
@@ -35,11 +37,14 @@ export function initOtelServerNode(opts: InitOtelServerNodeOpts): NodeSDK {
|
|||||||
const spanProcessors = bridge.spanProcessor
|
const spanProcessors = bridge.spanProcessor
|
||||||
? [new BatchSpanProcessor(bridge.spanProcessor as never)]
|
? [new BatchSpanProcessor(bridge.spanProcessor as never)]
|
||||||
: [];
|
: [];
|
||||||
|
const logRecordProcessors = bridge.logRecordProcessor
|
||||||
|
? [new BatchLogRecordProcessor(bridge.logRecordProcessor)]
|
||||||
|
: [];
|
||||||
|
|
||||||
const sdk = new NodeSDK({
|
const sdk = new NodeSDK({
|
||||||
resource,
|
resource,
|
||||||
spanProcessors,
|
spanProcessors,
|
||||||
// logRecordProcessors filled in Phase 3
|
logRecordProcessors,
|
||||||
// metricReader filled in Phase 4
|
// metricReader filled in Phase 4
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import type { LogRecord } from "@opentelemetry/sdk-logs";
|
||||||
|
|
||||||
beforeEach(() => vi.resetModules());
|
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", () => {
|
describe("createSentryOtelBridge", () => {
|
||||||
it("returns a span processor when given a DSN", async () => {
|
it("returns a span processor and log record processor when given a DSN", async () => {
|
||||||
// Mock @sentry/opentelemetry — we only verify the shape of what the bridge returns.
|
|
||||||
vi.doMock("@sentry/opentelemetry", () => ({
|
vi.doMock("@sentry/opentelemetry", () => ({
|
||||||
SentrySpanProcessor: class {
|
SentrySpanProcessor: class {
|
||||||
onStart() {}
|
onStart() {}
|
||||||
@@ -17,12 +24,16 @@ describe("createSentryOtelBridge", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
vi.doMock("@sentry/nextjs", () => ({
|
||||||
|
captureException: vi.fn(),
|
||||||
|
captureMessage: vi.fn(),
|
||||||
|
}));
|
||||||
const { createSentryOtelBridge } = await import("./sentry-bridge");
|
const { createSentryOtelBridge } = await import("./sentry-bridge");
|
||||||
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
|
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
|
||||||
expect(bridge.spanProcessor).toBeDefined();
|
expect(bridge.spanProcessor).toBeDefined();
|
||||||
// logRecordProcessor is null in Phase 1 — @sentry/opentelemetry 10.x does not
|
// Phase 3: logRecordProcessor is now wired (SentryLogRecordForwarder)
|
||||||
// export SentryLogRecordProcessor. It will be wired in Phase 3 via @sentry/node.
|
expect(bridge.logRecordProcessor).toBeDefined();
|
||||||
expect(bridge.logRecordProcessor).toBeNull();
|
expect(bridge.logRecordProcessor).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null processors when no DSN provided", async () => {
|
it("returns null processors when no DSN provided", async () => {
|
||||||
@@ -32,3 +43,101 @@ describe("createSentryOtelBridge", () => {
|
|||||||
expect(bridge.logRecordProcessor).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { tracing as sdkTracing, logs as sdkLogs } from "@opentelemetry/sdk-node";
|
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 SpanProcessor = sdkTracing.SpanProcessor;
|
||||||
type LogRecordProcessor = sdkLogs.LogRecordProcessor;
|
type LogRecordProcessor = sdkLogs.LogRecordProcessor;
|
||||||
@@ -10,19 +12,92 @@ export type SentryOtelBridgeOpts = {
|
|||||||
|
|
||||||
export type SentryOtelBridge = {
|
export type SentryOtelBridge = {
|
||||||
spanProcessor: SpanProcessor | null;
|
spanProcessor: SpanProcessor | null;
|
||||||
/**
|
|
||||||
* Log record processor slot. Null in Phase 1 — @sentry/opentelemetry 10.x does
|
|
||||||
* not export SentryLogRecordProcessor. This slot is filled in Phase 3 when the
|
|
||||||
* OTel Logger is introduced and wired via @sentry/node's log SDK support.
|
|
||||||
*/
|
|
||||||
logRecordProcessor: LogRecordProcessor | 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);
|
||||||
|
|
||||||
|
if (record.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 = record.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
|
* Creates Sentry-as-OTel-exporter processors. The OTel SDK uses these to
|
||||||
* forward spans (and, in Phase 3, log records) to Sentry. This is the ONLY
|
* forward spans and log records to Sentry. This is the ONLY file in
|
||||||
* file in core-shared that imports from `@sentry/opentelemetry` — all other
|
* core-shared that imports from `@sentry/opentelemetry` — all other Sentry
|
||||||
* Sentry coupling is excluded by the R40/R52 ESLint allowlist.
|
* coupling is excluded by the R40/R52 ESLint allowlist.
|
||||||
*/
|
*/
|
||||||
export function createSentryOtelBridge(opts: SentryOtelBridgeOpts): SentryOtelBridge {
|
export function createSentryOtelBridge(opts: SentryOtelBridgeOpts): SentryOtelBridge {
|
||||||
if (!opts.dsn) {
|
if (!opts.dsn) {
|
||||||
@@ -32,7 +107,28 @@ export function createSentryOtelBridge(opts: SentryOtelBridgeOpts): SentryOtelBr
|
|||||||
const sentryOtel = require("@sentry/opentelemetry");
|
const sentryOtel = require("@sentry/opentelemetry");
|
||||||
return {
|
return {
|
||||||
spanProcessor: new sentryOtel.SentrySpanProcessor() as SpanProcessor,
|
spanProcessor: new sentryOtel.SentrySpanProcessor() as SpanProcessor,
|
||||||
// logRecordProcessor wired in Phase 3 via @sentry/node logger support.
|
logRecordProcessor: new SentryLogRecordForwarder(),
|
||||||
logRecordProcessor: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user