Files
agentic-dev/packages/core-shared/src/instrumentation/otel/sentry-bridge.test.ts
Danijel Martinek ad64009e86 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>
2026-05-11 11:53:23 +02:00

144 lines
5.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { LogRecord } from "@opentelemetry/sdk-logs";
beforeEach(() => vi.resetModules());
// Use raw OTel SeverityNumber values to avoid module-reset issues with the
// top-level SeverityNumber import in test.each definitions.
// SeverityNumber.INFO = 9, SeverityNumber.WARN = 13, SeverityNumber.ERROR = 17
const SEVERITY_INFO = 9;
const SEVERITY_WARN = 13;
const SEVERITY_ERROR = 17;
describe("createSentryOtelBridge", () => {
it("returns a span processor and log record processor when given a DSN", async () => {
vi.doMock("@sentry/opentelemetry", () => ({
SentrySpanProcessor: class {
onStart() {}
onEnd() {}
forceFlush() {
return Promise.resolve();
}
shutdown() {
return Promise.resolve();
}
},
}));
vi.doMock("@sentry/nextjs", () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
}));
const { createSentryOtelBridge } = await import("./sentry-bridge");
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
expect(bridge.spanProcessor).toBeDefined();
// Phase 3: logRecordProcessor is now wired (SentryLogRecordForwarder)
expect(bridge.logRecordProcessor).toBeDefined();
expect(bridge.logRecordProcessor).not.toBeNull();
});
it("returns null processors when no DSN provided", async () => {
const { createSentryOtelBridge } = await import("./sentry-bridge");
const bridge = createSentryOtelBridge({ dsn: "" });
expect(bridge.spanProcessor).toBeNull();
expect(bridge.logRecordProcessor).toBeNull();
});
});
describe("SentryLogRecordForwarder", () => {
it("calls Sentry.captureException for ERROR records with exception attributes", async () => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
const record = {
severityNumber: SEVERITY_ERROR,
attributes: {
"exception.type": "TypeError",
"exception.message": "Cannot read property",
"exception.stacktrace": "TypeError: ...\n at foo.ts:10",
"tag.feature": "blog",
"extra.count": "5",
"sentry.fingerprint": "type-a|src-blog",
},
body: "Cannot read property",
} as unknown as LogRecord;
forwarder.onEmit(record);
expect(captureException).toHaveBeenCalledTimes(1);
const [err, opts] = captureException.mock.calls[0]!;
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("TypeError");
expect(err.message).toBe("Cannot read property");
expect(err.stack).toBe("TypeError: ...\n at foo.ts:10");
expect(opts.tags).toEqual({ feature: "blog" });
expect(opts.extra).toEqual({ count: "5" });
expect(opts.fingerprint).toEqual(["type-a", "src-blog"]);
expect(captureMessage).not.toHaveBeenCalled();
});
it("calls Sentry.captureException for ERROR records without exception.stacktrace", async () => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
const record = {
severityNumber: SEVERITY_ERROR,
attributes: {
"exception.message": "Something failed",
},
body: "Something failed",
} as unknown as LogRecord;
forwarder.onEmit(record);
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureMessage).not.toHaveBeenCalled();
});
it.each([
[SEVERITY_INFO, "info"],
[SEVERITY_WARN, "warning"],
] as const)(
"calls Sentry.captureMessage with level '%s' for non-error severity %d",
async (severityNumber, expectedLevel) => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const captureException = vi.fn();
const captureMessage = vi.fn();
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
const record = {
severityNumber,
attributes: { "tag.service": "auth", "extra.req": "abc" },
body: "log message",
} as unknown as LogRecord;
forwarder.onEmit(record);
expect(captureMessage).toHaveBeenCalledTimes(1);
const [msg, level, opts] = captureMessage.mock.calls[0]!;
expect(msg).toBe("log message");
expect(level).toBe(expectedLevel);
expect(opts.tags).toEqual({ service: "auth" });
expect(opts.extra).toEqual({ req: "abc" });
expect(captureException).not.toHaveBeenCalled();
},
);
it("forceFlush and shutdown resolve immediately", async () => {
const { SentryLogRecordForwarder } = await import("./sentry-bridge");
const forwarder = new SentryLogRecordForwarder({
captureException: vi.fn(),
captureMessage: vi.fn(),
});
await expect(forwarder.forceFlush()).resolves.toBeUndefined();
await expect(forwarder.shutdown()).resolves.toBeUndefined();
});
});