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>
144 lines
5.0 KiB
TypeScript
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();
|
|
});
|
|
});
|