diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index 7dbc7a3..7401b07 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -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:*", diff --git a/packages/core-shared/src/instrumentation/otel/otel-tracer.test.ts b/packages/core-shared/src/instrumentation/otel/otel-tracer.test.ts new file mode 100644 index 0000000..f20be99 --- /dev/null +++ b/packages/core-shared/src/instrumentation/otel/otel-tracer.test.ts @@ -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 + }); +}); diff --git a/packages/core-shared/src/instrumentation/otel/otel-tracer.ts b/packages/core-shared/src/instrumentation/otel/otel-tracer.ts new file mode 100644 index 0000000..02e5b1a --- /dev/null +++ b/packages/core-shared/src/instrumentation/otel/otel-tracer.ts @@ -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(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { + const attributes: Record = { + ...(opts.attributes + ? (Object.fromEntries( + Object.entries(opts.attributes).filter(([, v]) => v !== null), + ) as Record) + : {}), + ...(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(); + } + }, + ); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5002d19..e400777 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -542,6 +542,9 @@ importers: specifier: ^3.24.0 version: 3.25.76 devDependencies: + '@opentelemetry/context-async-hooks': + specifier: ^1.28.0 + version: 1.28.0(@opentelemetry/api@1.9.1) '@repo/core-eslint': specifier: workspace:* version: link:../core-eslint