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