From ad64009e86eef8ee8f6ddeb311cb64a221869a2a Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 11:53:23 +0200 Subject: [PATCH] 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 --- .../di/bind-otel-instrumentation.ts | 4 +- .../instrumentation/otel/init-server-node.ts | 11 +- .../otel/sentry-bridge.test.ts | 119 +++++++++++++++++- .../src/instrumentation/otel/sentry-bridge.ts | 116 +++++++++++++++-- 4 files changed, 230 insertions(+), 20 deletions(-) diff --git a/packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts b/packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts index b2049a4..828ae4a 100644 --- a/packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts +++ b/packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts @@ -1,7 +1,7 @@ // packages/core-shared/src/instrumentation/di/bind-otel-instrumentation.ts import type { Container } from "inversify"; 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 { INSTRUMENTATION_SYMBOLS } from "../symbols"; import type { ITracer, ILogger } from "../index"; @@ -31,7 +31,7 @@ export function bindOtelInstrumentation( }); const tracer = new OtelTracer(); - const logger = new SentryLogger(); // Phase 3 replaces with OtelLogger + const logger = new OtelLogger(); if (container.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) { container.unbind(INSTRUMENTATION_SYMBOLS.TRACER); diff --git a/packages/core-shared/src/instrumentation/otel/init-server-node.ts b/packages/core-shared/src/instrumentation/otel/init-server-node.ts index 92140ac..8d30541 100644 --- a/packages/core-shared/src/instrumentation/otel/init-server-node.ts +++ b/packages/core-shared/src/instrumentation/otel/init-server-node.ts @@ -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 { createSentryOtelBridge } from "./sentry-bridge"; const { BatchSpanProcessor } = tracing; +const { BatchLogRecordProcessor } = sdkLogs; export type InitOtelServerNodeOpts = { /** 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. * - Configures Resource attributes per OTel semantic conventions. * - 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. * * Caller is responsible for `sdk.shutdown()` on process exit. @@ -35,11 +37,14 @@ export function initOtelServerNode(opts: InitOtelServerNodeOpts): NodeSDK { const spanProcessors = bridge.spanProcessor ? [new BatchSpanProcessor(bridge.spanProcessor as never)] : []; + const logRecordProcessors = bridge.logRecordProcessor + ? [new BatchLogRecordProcessor(bridge.logRecordProcessor)] + : []; const sdk = new NodeSDK({ resource, spanProcessors, - // logRecordProcessors filled in Phase 3 + logRecordProcessors, // metricReader filled in Phase 4 }); diff --git a/packages/core-shared/src/instrumentation/otel/sentry-bridge.test.ts b/packages/core-shared/src/instrumentation/otel/sentry-bridge.test.ts index a888062..a336d91 100644 --- a/packages/core-shared/src/instrumentation/otel/sentry-bridge.test.ts +++ b/packages/core-shared/src/instrumentation/otel/sentry-bridge.test.ts @@ -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(); + }); +}); diff --git a/packages/core-shared/src/instrumentation/otel/sentry-bridge.ts b/packages/core-shared/src/instrumentation/otel/sentry-bridge.ts index 76f3b82..9ec63ed 100644 --- a/packages/core-shared/src/instrumentation/otel/sentry-bridge.ts +++ b/packages/core-shared/src/instrumentation/otel/sentry-bridge.ts @@ -1,4 +1,6 @@ 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; @@ -10,19 +12,92 @@ export type SentryOtelBridgeOpts = { export type SentryOtelBridge = { 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; }; +type SentryModule = { + captureException: ( + err: Error, + opts: { + tags?: Record; + extra?: Record; + fingerprint?: string[]; + }, + ) => void; + captureMessage: ( + msg: string, + level: string, + opts: { tags?: Record; extra?: Record }, + ) => 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 { + return Promise.resolve(); + } + + shutdown(): Promise { + return Promise.resolve(); + } +} + /** * 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 - * file in core-shared that imports from `@sentry/opentelemetry` — all other - * Sentry coupling is excluded by the R40/R52 ESLint allowlist. + * 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 R40/R52 ESLint allowlist. */ export function createSentryOtelBridge(opts: SentryOtelBridgeOpts): SentryOtelBridge { if (!opts.dsn) { @@ -32,7 +107,28 @@ export function createSentryOtelBridge(opts: SentryOtelBridgeOpts): SentryOtelBr const sentryOtel = require("@sentry/opentelemetry"); return { spanProcessor: new sentryOtel.SentrySpanProcessor() as SpanProcessor, - // logRecordProcessor wired in Phase 3 via @sentry/node logger support. - logRecordProcessor: null, + logRecordProcessor: new SentryLogRecordForwarder(), }; } + +// ── Attribute helpers ──────────────────────────────────────────────────────── + +function extractTags(attrs: Record): Record { + const tags: Record = {}; + for (const [k, v] of Object.entries(attrs)) { + if (k.startsWith("tag.")) { + tags[k.slice(4)] = String(v); + } + } + return tags; +} + +function extractExtras(attrs: Record): Record { + const extras: Record = {}; + for (const [k, v] of Object.entries(attrs)) { + if (k.startsWith("extra.")) { + extras[k.slice(6)] = v; + } + } + return extras; +}