From 7a19ea2703d622e39c1854cfae754c3645ab8305 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 3 Jun 2026 11:42:13 +0200 Subject: [PATCH 1/2] fix(otel): Transform v1 spans into v2 compatible format before exporting --- integrations/otel-js/otel-v1/package.json | 2 +- integrations/otel-js/otel-v2/package.json | 2 +- integrations/otel-js/package.json | 1 + integrations/otel-js/src/exporter.test.ts | 190 +++++++++++++++++++--- integrations/otel-js/src/otel.ts | 73 ++++++++- pnpm-lock.yaml | 3 + 6 files changed, 242 insertions(+), 29 deletions(-) diff --git a/integrations/otel-js/otel-v1/package.json b/integrations/otel-js/otel-v1/package.json index 7bbd48a93..0447dd990 100644 --- a/integrations/otel-js/otel-v1/package.json +++ b/integrations/otel-js/otel-v1/package.json @@ -19,7 +19,7 @@ "build": "tsup", "watch": "tsup --watch", "clean": "rm -rf dist/*", - "build:deps": "(cd ../../../.. && pnpm turbo run build --filter=braintrust --filter=@braintrust/otel)", + "build:deps": "(cd ../../.. && pnpm turbo run build --filter=braintrust --filter=@braintrust/otel)", "test": "pnpm run build:deps && vitest run" }, "author": "Braintrust Data Inc.", diff --git a/integrations/otel-js/otel-v2/package.json b/integrations/otel-js/otel-v2/package.json index d8a9e0e1d..b943a80a1 100644 --- a/integrations/otel-js/otel-v2/package.json +++ b/integrations/otel-js/otel-v2/package.json @@ -19,7 +19,7 @@ "build": "tsup", "watch": "tsup --watch", "clean": "rm -rf dist/*", - "build:deps": "(cd ../../../.. && pnpm turbo run build --filter=braintrust --filter=@braintrust/otel)", + "build:deps": "(cd ../../.. && pnpm turbo run build --filter=braintrust --filter=@braintrust/otel)", "test": "pnpm run build:deps && vitest run" }, "author": "Braintrust Data Inc.", diff --git a/integrations/otel-js/package.json b/integrations/otel-js/package.json index 15c1502d8..416998896 100644 --- a/integrations/otel-js/package.json +++ b/integrations/otel-js/package.json @@ -27,6 +27,7 @@ "author": "Braintrust Data Inc.", "license": "MIT", "devDependencies": { + "@opentelemetry/context-async-hooks": "2.6.1", "@types/node": "^22.15.21", "braintrust": "workspace:*", "tsup": "^8.5.0", diff --git a/integrations/otel-js/src/exporter.test.ts b/integrations/otel-js/src/exporter.test.ts index d29e2f2fc..94168963a 100644 --- a/integrations/otel-js/src/exporter.test.ts +++ b/integrations/otel-js/src/exporter.test.ts @@ -6,58 +6,105 @@ import { afterAll, beforeEach, afterEach, + vi, } from "vitest"; -import { setupServer } from "msw/node"; -import { http, HttpResponse } from "msw"; -import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + diag, + DiagLogLevel, + TraceFlags, + type DiagLogger, +} from "@opentelemetry/api"; +import { + BasicTracerProvider, + type ReadableSpan, +} from "@opentelemetry/sdk-trace-base"; import { BraintrustSpanProcessor } from "./otel"; import { createTracerProvider } from "../tests/utils"; import { _exportsForTestingOnly } from "braintrust"; +type OtlpTraceRequest = { + resourceSpans?: Array<{ + scopeSpans?: Array<{ + scope?: { + name?: string; + version?: string; + }; + spans?: Array<{ + name?: string; + parentSpanId?: string; + }>; + }>; + }>; +}; + describe("BraintrustSpanProcessor - Real HTTP Exporter", () => { const TEST_API_KEY = "test-api-key-12345"; - const TEST_API_URL = "https://test-api.braintrust.dev"; const TEST_PARENT = "project_name:test-export-project"; + let testApiUrl: string; + let server: Server; + let failExports = false; let capturedRequests: Array<{ url: string; headers: Record; body: unknown; }> = []; - const server = setupServer( - http.post(`${TEST_API_URL}/otel/v1/traces`, async ({ request }) => { - const body = await request.json(); - const headers: Record = {}; - request.headers.forEach((value, key) => { - headers[key] = value; - }); + beforeAll(async () => { + server = createServer((req, res) => { + if (req.method !== "POST" || req.url !== "/otel/v1/traces") { + res.writeHead(404).end(); + return; + } - capturedRequests.push({ - url: request.url, - headers, - body, - }); + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + if (failExports) { + res.writeHead(500).end("export failed"); + return; + } - return HttpResponse.json({ success: true }, { status: 200 }); - }), - ); + capturedRequests.push({ + url: new URL(req.url ?? "/", testApiUrl).href, + headers: Object.fromEntries( + Object.entries(req.headers).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(", ") : (value ?? ""), + ]), + ), + body: JSON.parse(Buffer.concat(chunks).toString()), + }); - beforeAll(() => { - server.listen({ onUnhandledRequest: "error" }); + res.writeHead(200, { "content-type": "application/json" }).end("{}"); + }); + }); + + testApiUrl = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve(`http://127.0.0.1:${address.port}`); + }); + }); }); - afterAll(() => { - server.close(); + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); }); beforeEach(async () => { capturedRequests = []; + failExports = false; await _exportsForTestingOnly.simulateLoginForTests(); _exportsForTestingOnly.useTestBackgroundLogger(); }); afterEach(() => { + diag.disable(); _exportsForTestingOnly.clearTestBackgroundLogger(); _exportsForTestingOnly.simulateLogoutForTests(); }); @@ -66,7 +113,7 @@ describe("BraintrustSpanProcessor - Real HTTP Exporter", () => { // Create processor WITHOUT _spanProcessor to test real exporter path const processor = new BraintrustSpanProcessor({ apiKey: TEST_API_KEY, - apiUrl: TEST_API_URL, + apiUrl: testApiUrl, parent: TEST_PARENT, }); @@ -88,7 +135,7 @@ describe("BraintrustSpanProcessor - Real HTTP Exporter", () => { const request = capturedRequests[0]; // Verify URL - expect(request.url).toBe(`${TEST_API_URL}/otel/v1/traces`); + expect(request.url).toBe(`${testApiUrl}/otel/v1/traces`); // Verify headers expect(request.headers["authorization"]).toBe(`Bearer ${TEST_API_KEY}`); @@ -102,7 +149,7 @@ describe("BraintrustSpanProcessor - Real HTTP Exporter", () => { it("should work with filterAISpans enabled", async () => { const processor = new BraintrustSpanProcessor({ apiKey: TEST_API_KEY, - apiUrl: TEST_API_URL, + apiUrl: testApiUrl, parent: TEST_PARENT, filterAISpans: true, }); @@ -123,4 +170,95 @@ describe("BraintrustSpanProcessor - Real HTTP Exporter", () => { expect(capturedRequests.length).toBeGreaterThanOrEqual(1); expect(capturedRequests[0].body).toHaveProperty("resourceSpans"); }); + + it("should export OTel 1.x-shaped spans through newer OTLP exporters", async () => { + const processor = new BraintrustSpanProcessor({ + apiKey: TEST_API_KEY, + apiUrl: testApiUrl, + parent: TEST_PARENT, + }); + const parentSpanId = "3333333333333333"; + const v1Span = { + name: "gen_ai.completion", + spanContext: () => ({ + traceId: "11111111111111111111111111111111", + spanId: "2222222222222222", + traceFlags: TraceFlags.SAMPLED, + }), + parentSpanId, + instrumentationLibrary: { name: "otel-v1-lib", version: "1.2.3" }, + kind: 0, + startTime: [0, 0], + endTime: [0, 1], + status: { code: 0 }, + attributes: {}, + events: [], + links: [], + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + resource: { + attributes: { + "service.name": "otel-v1-service", + }, + asyncAttributesPending: false, + }, + } as unknown as ReadableSpan; + + processor.onEnd(v1Span); + await processor.forceFlush(); + await processor.shutdown(); + + expect(capturedRequests.length).toBeGreaterThanOrEqual(1); + const body = capturedRequests[0].body as OtlpTraceRequest; + const scopeSpan = body.resourceSpans?.[0]?.scopeSpans?.[0]; + const exportedSpan = scopeSpan?.spans?.[0]; + + expect(scopeSpan?.scope).toEqual({ + name: "otel-v1-lib", + version: "1.2.3", + }); + expect(exportedSpan?.name).toBe("gen_ai.completion"); + expect(exportedSpan?.parentSpanId).toBe(parentSpanId); + expect("instrumentationScope" in v1Span).toBe(false); + expect("parentSpanContext" in v1Span).toBe(false); + }); + + it("should report export failures through OTel diagnostics", async () => { + failExports = true; + + const diagErrors: unknown[][] = []; + const testDiagLogger: DiagLogger = { + error: (...args) => diagErrors.push(args), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + verbose: vi.fn(), + }; + diag.setLogger(testDiagLogger, { + logLevel: DiagLogLevel.ERROR, + suppressOverrideMessage: true, + }); + + const processor = new BraintrustSpanProcessor({ + apiKey: TEST_API_KEY, + apiUrl: testApiUrl, + parent: TEST_PARENT, + }); + const provider = createTracerProvider(BasicTracerProvider, [processor]); + const tracer = provider.getTracer("test-tracer"); + const span = tracer.startSpan("test-span"); + span.end(); + + await expect(processor.forceFlush()).rejects.toThrow(); + await provider.shutdown().catch(() => undefined); + + expect( + diagErrors.some( + (args) => + args[0] === "@braintrust/otel" && + args[1] === "Braintrust OTLP span export failed", + ), + ).toBe(true); + }); }); diff --git a/integrations/otel-js/src/otel.ts b/integrations/otel-js/src/otel.ts index a4524274a..e588248d5 100644 --- a/integrations/otel-js/src/otel.ts +++ b/integrations/otel-js/src/otel.ts @@ -4,6 +4,7 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { context, Context, + diag, trace, TraceFlags, propagation, @@ -200,6 +201,9 @@ interface BraintrustSpanProcessorOptions { } class LazyBraintrustOTLPTraceExporter implements SpanExporter { + private readonly diagLogger = diag.createComponentLogger({ + namespace: "@braintrust/otel", + }); private exporter?: OTLPTraceExporter; private exporterPromise?: Promise; @@ -219,10 +223,77 @@ class LazyBraintrustOTLPTraceExporter implements SpanExporter { resultCallback: (result: ExportResult) => void, ): void { void this.getExporter() - .then((exporter) => exporter.export(spans, resultCallback)) + .then((exporter) => { + const compatibleSpans = spans.map((span) => { + const spanWithCompatFields = span as ReadableSpan & { + instrumentationLibrary?: unknown; + instrumentationScope?: unknown; + parentSpanContext?: { spanId?: string; isRemote?: boolean }; + parentSpanId?: string; + }; + const instrumentationScope = + spanWithCompatFields.instrumentationScope ?? + spanWithCompatFields.instrumentationLibrary; + const instrumentationLibrary = + spanWithCompatFields.instrumentationLibrary ?? + spanWithCompatFields.instrumentationScope; + const parentSpanContext = + spanWithCompatFields.parentSpanContext ?? + (spanWithCompatFields.parentSpanId + ? { + ...span.spanContext(), + spanId: spanWithCompatFields.parentSpanId, + isRemote: false, + } + : undefined); + const parentSpanId = + spanWithCompatFields.parentSpanId ?? + spanWithCompatFields.parentSpanContext?.spanId; + + return new Proxy(span, { + get(target, prop) { + if (prop === "instrumentationScope") { + return instrumentationScope; + } + if (prop === "instrumentationLibrary") { + return instrumentationLibrary; + } + if (prop === "parentSpanContext") { + return parentSpanContext; + } + if (prop === "parentSpanId") { + return parentSpanId; + } + + const value = Reflect.get(target, prop, target); + return typeof value === "function" ? value.bind(target) : value; + }, + has(target, prop) { + return ( + prop === "instrumentationScope" || + prop === "instrumentationLibrary" || + prop === "parentSpanContext" || + prop === "parentSpanId" || + prop in target + ); + }, + }); + }); + + exporter.export(compatibleSpans, (result) => { + if (result.code !== 0) { + this.diagLogger.error( + "Braintrust OTLP span export failed", + result.error, + ); + } + resultCallback(result); + }); + }) .catch((error) => { const errorObj = error instanceof Error ? error : new Error(String(error)); + this.diagLogger.error("Braintrust OTLP span export failed", errorObj); resultCallback({ code: 1, error: errorObj }); }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52dd0f6dd..263fa0049 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: specifier: '>=1.9.0' version: 2.6.1(@opentelemetry/api@1.9.0) devDependencies: + '@opentelemetry/context-async-hooks': + specifier: 2.6.1 + version: 2.6.1(@opentelemetry/api@1.9.0) '@types/node': specifier: ^22.15.21 version: 22.19.1 From f5b6081440d269c0a46ca50c9fe59f0716877e3f Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 3 Jun 2026 11:42:34 +0200 Subject: [PATCH 2/2] cs --- .changeset/calm-scope-sparkle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/calm-scope-sparkle.md diff --git a/.changeset/calm-scope-sparkle.md b/.changeset/calm-scope-sparkle.md new file mode 100644 index 000000000..38b063c9a --- /dev/null +++ b/.changeset/calm-scope-sparkle.md @@ -0,0 +1,5 @@ +--- +"@braintrust/otel": patch +--- + +fix(otel): Transform v1 spans into v2 compatible format before exporting