feat(core-shared): OtelTracer impl using @opentelemetry/api

Implements OtelTracer as ITracer replacement for SentryTracer. Uses
@opentelemetry/api's trace.getTracer + startActiveSpan, maps span.op to
a span attribute, catches exceptions via recordException + setStatus(ERROR),
and filters null attribute values. Five Vitest tests covering name/attributes,
nesting, exception recording, status mapping, and null filtering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 11:40:43 +02:00
parent 4c628022ce
commit b61e9d2091
4 changed files with 156 additions and 0 deletions

View File

@@ -50,6 +50,7 @@
"@sentry/react": { "optional": true }
},
"devDependencies": {
"@opentelemetry/context-async-hooks": "^1.28.0",
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { context, trace } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OtelTracer } from "./otel-tracer";
// Register the async context manager once for the entire test file.
// This must be done before any OtelTracer is constructed.
const ctxManager = new AsyncLocalStorageContextManager();
ctxManager.enable();
context.setGlobalContextManager(ctxManager);
function setupProvider(): { exporter: InMemorySpanExporter; provider: BasicTracerProvider } {
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
return { exporter, provider };
}
describe("OtelTracer", () => {
let exporter: InMemorySpanExporter;
let provider: BasicTracerProvider;
beforeEach(() => {
({ exporter, provider } = setupProvider());
});
afterEach(async () => {
await provider.shutdown();
trace.disable();
});
it("records span name and op attribute", async () => {
const tracer = new OtelTracer();
await tracer.startSpan({ name: "blog.getArticles", op: "use-case" }, async () => "value");
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
const [span] = spans;
expect(span!.name).toBe("blog.getArticles");
expect(span!.attributes["span.op"]).toBe("use-case");
});
it("records additional attributes, filtering out null values", async () => {
const tracer = new OtelTracer();
await tracer.startSpan(
{
name: "articles.findAll",
op: "repository",
attributes: { collection: "articles", limit: 10, tag: null },
},
async () => undefined,
);
const [span] = exporter.getFinishedSpans();
expect(span!.attributes["collection"]).toBe("articles");
expect(span!.attributes["limit"]).toBe(10);
expect(span!.attributes["tag"]).toBeUndefined();
});
it("nested spans — child span has parent span as its parent", async () => {
const tracer = new OtelTracer();
await tracer.startSpan({ name: "parent" }, async () => {
await tracer.startSpan({ name: "child" }, async () => "child-result");
return "parent-result";
});
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(2);
const child = spans.find((s) => s.name === "child")!;
const parent = spans.find((s) => s.name === "parent")!;
// In sdk-trace-base@1.30.x the parent-child link is tracked via parentSpanId (string)
// Both spans are in the same trace
expect(child.spanContext().traceId).toBe(parent.spanContext().traceId);
// The child's parentSpanId should be the parent's span ID
expect(child.parentSpanId).toBe(parent.spanContext().spanId);
});
it("records exception and sets ERROR status on throw", async () => {
const tracer = new OtelTracer();
await expect(
tracer.startSpan({ name: "failing-op" }, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
const [span] = exporter.getFinishedSpans();
expect(span!.status.code).toBe(2); // SpanStatusCode.ERROR = 2
const exceptionEvent = span!.events.find((e) => e.name === "exception");
expect(exceptionEvent).toBeDefined();
expect(exceptionEvent!.attributes!["exception.message"]).toBe("boom");
});
it("ISpan adapter: setAttribute ignores null; setStatus maps ok/error", async () => {
const tracer = new OtelTracer();
await tracer.startSpan({ name: "adapter-test" }, async (span) => {
span.setAttribute("key", "value");
span.setAttribute("nullable", null);
span.setStatus("ok");
return undefined;
});
const [span] = exporter.getFinishedSpans();
expect(span!.attributes["key"]).toBe("value");
expect(span!.attributes["nullable"]).toBeUndefined();
expect(span!.status.code).toBe(1); // SpanStatusCode.OK = 1
});
});

View File

@@ -0,0 +1,46 @@
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import type { ITracer, ISpan, SpanOpts } from "../tracer.interface";
export class OtelTracer implements ITracer {
private readonly tracer = trace.getTracer("@repo/core-shared", "1.0.0");
async startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
const attributes: Record<string, string | number | boolean> = {
...(opts.attributes
? (Object.fromEntries(
Object.entries(opts.attributes).filter(([, v]) => v !== null),
) as Record<string, string | number | boolean>)
: {}),
...(opts.op ? { "span.op": opts.op } : {}),
};
return this.tracer.startActiveSpan(
opts.name,
{ kind: SpanKind.INTERNAL, attributes },
async (otelSpan) => {
const adapter: ISpan = {
setAttribute(key, value) {
if (value !== null) {
otelSpan.setAttribute(key, value as string | number | boolean);
}
},
setStatus(status, message) {
otelSpan.setStatus({
code: status === "ok" ? SpanStatusCode.OK : SpanStatusCode.ERROR,
message,
});
},
};
try {
return await fn(adapter);
} catch (err) {
otelSpan.recordException(err as Error);
otelSpan.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
otelSpan.end();
}
},
);
}
}