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