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 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 11:49:22 +02:00
parent 3f40ca773b
commit 5e795fd7ab
2 changed files with 306 additions and 0 deletions

View File

@@ -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<string, unknown>)["__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();
});
});
});

View File

@@ -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<string, string>): Record<string, string> {
if (!tags) return {};
return Object.fromEntries(Object.entries(tags).map(([k, v]) => [`tag.${k}`, v]));
}
function flattenExtras(extras?: Record<string, unknown>): Record<string, string> {
if (!extras) return {};
return Object.fromEntries(
Object.entries(extras).map(([k, v]) => [
`extra.${k}`,
typeof v === "string" ? v : String(v),
]),
);
}