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,10 +1,17 @@
|
||||
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 when given a DSN", async () => {
|
||||
// Mock @sentry/opentelemetry — we only verify the shape of what the bridge returns.
|
||||
it("returns a span processor and log record processor when given a DSN", async () => {
|
||||
vi.doMock("@sentry/opentelemetry", () => ({
|
||||
SentrySpanProcessor: class {
|
||||
onStart() {}
|
||||
@@ -17,12 +24,16 @@ describe("createSentryOtelBridge", () => {
|
||||
}
|
||||
},
|
||||
}));
|
||||
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 null in Phase 1 — @sentry/opentelemetry 10.x does not
|
||||
// export SentryLogRecordProcessor. It will be wired in Phase 3 via @sentry/node.
|
||||
expect(bridge.logRecordProcessor).toBeNull();
|
||||
// Phase 3: logRecordProcessor is now wired (SentryLogRecordForwarder)
|
||||
expect(bridge.logRecordProcessor).toBeDefined();
|
||||
expect(bridge.logRecordProcessor).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null processors when no DSN provided", async () => {
|
||||
@@ -32,3 +43,101 @@ describe("createSentryOtelBridge", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user