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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
76
packages/core-shared/src/instrumentation/otel/otel-logger.ts
Normal file
76
packages/core-shared/src/instrumentation/otel/otel-logger.ts
Normal 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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user