Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-scope-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@braintrust/otel": patch
---

fix(otel): Transform v1 spans into v2 compatible format before exporting
2 changes: 1 addition & 1 deletion integrations/otel-js/otel-v1/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion integrations/otel-js/otel-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions integrations/otel-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
190 changes: 164 additions & 26 deletions integrations/otel-js/src/exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
body: unknown;
}> = [];

const server = setupServer(
http.post(`${TEST_API_URL}/otel/v1/traces`, async ({ request }) => {
const body = await request.json();
const headers: Record<string, string> = {};
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<string>((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<void>((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();
});
Expand All @@ -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,
});

Expand All @@ -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}`);
Expand All @@ -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,
});
Expand All @@ -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);
});
});
73 changes: 72 additions & 1 deletion integrations/otel-js/src/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import {
context,
Context,
diag,
trace,
TraceFlags,
propagation,
Expand Down Expand Up @@ -200,6 +201,9 @@ interface BraintrustSpanProcessorOptions {
}

class LazyBraintrustOTLPTraceExporter implements SpanExporter {
private readonly diagLogger = diag.createComponentLogger({
namespace: "@braintrust/otel",
});
private exporter?: OTLPTraceExporter;
private exporterPromise?: Promise<OTLPTraceExporter>;

Expand All @@ -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 });
});
}
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading