From 5e795fd7ab880d6b5f1465932db042ed2349016f Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 11:49:22 +0200 Subject: [PATCH] feat(core-shared): OtelLogger impl using @opentelemetry/api-logs Implements ILogger via OTel Logs API. captureException emits an ERROR log record with exception.* semantic conventions and double-report guard; captureMessage maps ILogger levels to OTel SeverityNumber; addBreadcrumb attaches span events; setUser sets user.id span attribute. 15 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../instrumentation/otel/otel-logger.test.ts | 230 ++++++++++++++++++ .../src/instrumentation/otel/otel-logger.ts | 76 ++++++ 2 files changed, 306 insertions(+) create mode 100644 packages/core-shared/src/instrumentation/otel/otel-logger.test.ts create mode 100644 packages/core-shared/src/instrumentation/otel/otel-logger.ts diff --git a/packages/core-shared/src/instrumentation/otel/otel-logger.test.ts b/packages/core-shared/src/instrumentation/otel/otel-logger.test.ts new file mode 100644 index 0000000..a33eb87 --- /dev/null +++ b/packages/core-shared/src/instrumentation/otel/otel-logger.test.ts @@ -0,0 +1,230 @@ +// packages/core-shared/src/instrumentation/otel/otel-logger.test.ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { context, trace } from "@opentelemetry/api"; +import { logs, SeverityNumber } from "@opentelemetry/api-logs"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { + LoggerProvider, + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, +} from "@opentelemetry/sdk-logs"; +import { OtelLogger } from "./otel-logger"; + +// Enable async context manager for span context propagation +const ctxManager = new AsyncLocalStorageContextManager(); +ctxManager.enable(); +context.setGlobalContextManager(ctxManager); + +function setupProviders(): { + logExporter: InMemoryLogRecordExporter; + spanExporter: InMemorySpanExporter; + loggerProvider: LoggerProvider; + tracerProvider: BasicTracerProvider; +} { + const logExporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider(); + loggerProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter)); + logs.setGlobalLoggerProvider(loggerProvider); + + const spanExporter = new InMemorySpanExporter(); + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(spanExporter)], + }); + trace.setGlobalTracerProvider(tracerProvider); + + return { logExporter, spanExporter, loggerProvider, tracerProvider }; +} + +describe("OtelLogger", () => { + let logExporter: InMemoryLogRecordExporter; + let spanExporter: InMemorySpanExporter; + let loggerProvider: LoggerProvider; + let tracerProvider: BasicTracerProvider; + + beforeEach(() => { + ({ logExporter, spanExporter, loggerProvider, tracerProvider } = setupProviders()); + }); + + afterEach(async () => { + await loggerProvider.shutdown(); + await tracerProvider.shutdown(); + logs.disable(); + trace.disable(); + }); + + describe("captureException", () => { + it("emits a log record with ERROR severity and exception attributes", () => { + const logger = new OtelLogger(); + const err = new Error("something broke"); + err.name = "CustomError"; + + logger.captureException(err, { tags: { feature: "blog" }, extras: { userId: "u1" } }); + + const records = logExporter.getFinishedLogRecords(); + expect(records).toHaveLength(1); + + const [record] = records; + expect(record!.severityNumber).toBe(SeverityNumber.ERROR); + expect(record!.severityText).toBe("ERROR"); + expect(record!.body).toBe("something broke"); + expect(record!.attributes["exception.type"]).toBe("CustomError"); + expect(record!.attributes["exception.message"]).toBe("something broke"); + expect(typeof record!.attributes["exception.stacktrace"]).toBe("string"); + // Tags are prefixed with "tag." + expect(record!.attributes["tag.feature"]).toBe("blog"); + // Extras are prefixed with "extra." + expect(record!.attributes["extra.userId"]).toBe("u1"); + }); + + it("applies sentry.fingerprint attribute when provided", () => { + const logger = new OtelLogger(); + const err = new Error("fingerprinted"); + logger.captureException(err, { fingerprint: ["type-a", "src-blog"] }); + + const [record] = logExporter.getFinishedLogRecords(); + expect(record!.attributes["sentry.fingerprint"]).toBe("type-a|src-blog"); + }); + + it("is a no-op on second call for the same error (double-report guard)", () => { + const logger = new OtelLogger(); + const err = new Error("once"); + + logger.captureException(err); + logger.captureException(err); // should be skipped + + expect(logExporter.getFinishedLogRecords()).toHaveLength(1); + }); + + it("marks error as reported after first call", () => { + const logger = new OtelLogger(); + const err = new Error("mark-test"); + logger.captureException(err); + + expect( + (err as unknown as Record)["__sentryReported"], + ).toBe(true); + }); + + it("wraps non-Error values into an Error object", () => { + const logger = new OtelLogger(); + logger.captureException("plain string error"); + + const [record] = logExporter.getFinishedLogRecords(); + expect(record!.body).toBe("plain string error"); + expect(record!.attributes["exception.message"]).toBe("plain string error"); + }); + }); + + describe("captureMessage severity mapping", () => { + it.each([ + ["info" as const, SeverityNumber.INFO, "INFO"], + ["warning" as const, SeverityNumber.WARN, "WARNING"], + ["error" as const, SeverityNumber.ERROR, "ERROR"], + ] as const)( + "level %s → severityNumber %d, severityText %s", + (level, expectedNumber, expectedText) => { + const logger = new OtelLogger(); + logger.captureMessage("test message", level); + + const [record] = logExporter.getFinishedLogRecords(); + expect(record!.severityNumber).toBe(expectedNumber); + expect(record!.severityText).toBe(expectedText); + expect(record!.body).toBe("test message"); + }, + ); + + it("defaults to INFO when level is omitted", () => { + const logger = new OtelLogger(); + logger.captureMessage("default level"); + + const [record] = logExporter.getFinishedLogRecords(); + expect(record!.severityNumber).toBe(SeverityNumber.INFO); + expect(record!.severityText).toBe("INFO"); + }); + + it("includes tags and extras as prefixed attributes", () => { + const logger = new OtelLogger(); + logger.captureMessage("msg", "warning", { + tags: { service: "auth" }, + extras: { count: 5 }, + }); + + const [record] = logExporter.getFinishedLogRecords(); + expect(record!.attributes["tag.service"]).toBe("auth"); + expect(record!.attributes["extra.count"]).toBe("5"); + }); + }); + + describe("addBreadcrumb", () => { + it("adds a span event when there is an active span", async () => { + const logger = new OtelLogger(); + const otelTracer = trace.getTracer("test"); + + await otelTracer.startActiveSpan("test-span", async (span) => { + logger.addBreadcrumb({ + category: "http", + message: "GET /api/blog", + level: "info", + data: { status: 200 }, + }); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const [span] = spans; + const breadcrumbEvent = span!.events.find((e) => e.name === "GET /api/blog"); + expect(breadcrumbEvent).toBeDefined(); + expect(breadcrumbEvent!.attributes!["breadcrumb.category"]).toBe("http"); + expect(breadcrumbEvent!.attributes!["breadcrumb.level"]).toBe("info"); + expect(breadcrumbEvent!.attributes!["extra.status"]).toBe("200"); + }); + + it("is a no-op when there is no active span", () => { + const logger = new OtelLogger(); + // No span active — should not throw + expect(() => + logger.addBreadcrumb({ category: "nav", message: "page changed" }), + ).not.toThrow(); + }); + }); + + describe("setUser", () => { + it("sets user.id attribute on the active span", async () => { + const logger = new OtelLogger(); + const otelTracer = trace.getTracer("test"); + + await otelTracer.startActiveSpan("user-span", async (span) => { + logger.setUser({ id: "user-123" }); + span.end(); + }); + + const [span] = spanExporter.getFinishedSpans(); + expect(span!.attributes["user.id"]).toBe("user-123"); + }); + + it("sets user.id to empty string when called with null", async () => { + const logger = new OtelLogger(); + const otelTracer = trace.getTracer("test"); + + await otelTracer.startActiveSpan("logout-span", async (span) => { + logger.setUser(null); + span.end(); + }); + + const [span] = spanExporter.getFinishedSpans(); + expect(span!.attributes["user.id"]).toBe(""); + }); + + it("is a no-op when there is no active span", () => { + const logger = new OtelLogger(); + expect(() => logger.setUser({ id: "u1" })).not.toThrow(); + }); + }); +}); diff --git a/packages/core-shared/src/instrumentation/otel/otel-logger.ts b/packages/core-shared/src/instrumentation/otel/otel-logger.ts new file mode 100644 index 0000000..a66fd25 --- /dev/null +++ b/packages/core-shared/src/instrumentation/otel/otel-logger.ts @@ -0,0 +1,76 @@ +// packages/core-shared/src/instrumentation/otel/otel-logger.ts +import { logs, SeverityNumber } from "@opentelemetry/api-logs"; +import { trace } from "@opentelemetry/api"; +import { isReported, markReported } from "../reported-flag"; +import type { ILogger, Breadcrumb, CaptureContext } from "../logger.interface"; + +export class OtelLogger implements ILogger { + private readonly logger = logs.getLogger("@repo/core-shared", "1.0.0"); + + captureException(err: unknown, ctx?: CaptureContext): void { + if (isReported(err)) return; + markReported(err); + const error = err instanceof Error ? err : new Error(String(err)); + this.logger.emit({ + severityNumber: SeverityNumber.ERROR, + severityText: "ERROR", + body: error.message, + attributes: { + "exception.type": error.name, + "exception.message": error.message, + "exception.stacktrace": error.stack ?? "", + ...flattenTags(ctx?.tags), + ...flattenExtras(ctx?.extras), + ...(ctx?.fingerprint ? { "sentry.fingerprint": ctx.fingerprint.join("|") } : {}), + }, + }); + } + + captureMessage(msg: string, level?: "info" | "warning" | "error", ctx?: CaptureContext): void { + const severityNumber = + level === "error" + ? SeverityNumber.ERROR + : level === "warning" + ? SeverityNumber.WARN + : SeverityNumber.INFO; + const severityText = + level === "error" ? "ERROR" : level === "warning" ? "WARNING" : "INFO"; + this.logger.emit({ + severityNumber, + severityText, + body: msg, + attributes: { ...flattenTags(ctx?.tags), ...flattenExtras(ctx?.extras) }, + }); + } + + addBreadcrumb(b: Breadcrumb): void { + const span = trace.getActiveSpan(); + if (!span) return; + span.addEvent(b.message, { + "breadcrumb.category": b.category, + "breadcrumb.level": b.level ?? "info", + ...(b.data ? flattenExtras(b.data) : {}), + }); + } + + setUser(user: { id: string } | null): void { + const span = trace.getActiveSpan(); + if (!span) return; + span.setAttribute("user.id", user?.id ?? ""); + } +} + +function flattenTags(tags?: Record): Record { + if (!tags) return {}; + return Object.fromEntries(Object.entries(tags).map(([k, v]) => [`tag.${k}`, v])); +} + +function flattenExtras(extras?: Record): Record { + if (!extras) return {}; + return Object.fromEntries( + Object.entries(extras).map(([k, v]) => [ + `extra.${k}`, + typeof v === "string" ? v : String(v), + ]), + ); +}