Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/span-origin-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"braintrust": minor
Comment thread
Qard marked this conversation as resolved.
"@braintrust/otel": minor
---

Add span origin provenance metadata to Braintrust and OpenTelemetry spans.
53 changes: 53 additions & 0 deletions e2e/helpers/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,24 @@ function normalizeObject(
return [];
}

if (key === "span_origin") {
return [];
}

if (key === "braintrust.context_json" && typeof entry === "string") {
const normalizedContextJson = normalizeJsonString(
entry,
tokenMaps,
options,
);
if (normalizedContextJson === "{}") {
return [];
}
if (normalizedContextJson) {
return [[key, normalizedContextJson]];
}
}

if (isNodeInternalCaller) {
if (key === "caller_filename") {
return [[key, "<node-internal>"]];
Expand All @@ -283,6 +301,16 @@ function normalizeObject(
return [[key, tokenFor(tokenMaps.ids, entry, "signature")]];
}

if (
key === "environment" &&
entry &&
typeof entry === "object" &&
!Array.isArray(entry) &&
"type" in entry
) {
return [];
}

return [[key, normalizeValue(entry as Json, tokenMaps, options, key)]];
}),
);
Expand Down Expand Up @@ -341,6 +369,17 @@ function normalizeValue(
}

if (typeof value === "string") {
if (currentKey === "braintrust.context_json") {
const normalizedContextJson = normalizeJsonString(
value,
tokenMaps,
options,
);
if (normalizedContextJson) {
return normalizedContextJson;
}
}

value = normalizeStackLikeString(value);

const normalizedUrl = normalizeMockServerUrl(value);
Expand Down Expand Up @@ -461,6 +500,20 @@ function normalizeValue(
return value;
}

function normalizeJsonString(
value: string,
tokenMaps: TokenMaps,
options: ResolvedNormalizeOptions,
): string | undefined {
try {
return JSON.stringify(
normalizeValue(JSON.parse(value) as Json, tokenMaps, options),
);
} catch {
return undefined;
}
}

function resolveNormalizeOptions(
options: NormalizeOptions | undefined,
): ResolvedNormalizeOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,11 @@
]
},
{
"context": {},
"context": {
"caller_filename": "<node-internal>",
"caller_functionname": "<node-internal>",
"caller_lineno": 0
},
"created": "<timestamp>",
"id": "<span:11>",
"input": [
Expand Down
7 changes: 7 additions & 0 deletions integrations/otel-js/otel-v1/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { readFileSync } from "node:fs";
import { defineConfig } from "vitest/config";
import {
detectOtelVersion,
logOtelVersions,
createOtelAliases,
} from "../tests/utils";

const packageJson = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as { version: string };
const cwd = process.cwd();
const version = detectOtelVersion(cwd);

logOtelVersions(version);

export default defineConfig({
define: {
__BRAINTRUST_OTEL_VERSION__: JSON.stringify(packageJson.version),
},
resolve:
version !== "parent"
? {
Expand Down
7 changes: 7 additions & 0 deletions integrations/otel-js/otel-v2/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { readFileSync } from "node:fs";
import { defineConfig } from "vitest/config";
import {
detectOtelVersion,
logOtelVersions,
createOtelAliases,
} from "../tests/utils";

const packageJson = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as { version: string };
const cwd = process.cwd();
const version = detectOtelVersion(cwd);

logOtelVersions(version);

export default defineConfig({
define: {
__BRAINTRUST_OTEL_VERSION__: JSON.stringify(packageJson.version),
},
resolve:
version !== "parent"
? {
Expand Down
200 changes: 198 additions & 2 deletions integrations/otel-js/src/otel.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { readFileSync } from "node:fs";
import { describe, it, expect, beforeEach, afterEach, vi, test } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -34,13 +35,18 @@ import {
import { SpanComponentsV3, SpanComponentsV4 } from "braintrust/util";
import { setupOtelCompat, resetOtelCompat } from ".";

async function withEmptyBraintrustEnvFile<T>(
const packageJson = JSON.parse(
readFileSync(path.join(__dirname, "../package.json"), "utf8"),
) as { version: string };

async function withBraintrustEnvFile<T>(
content: string,
fn: () => T | Promise<T>,
): Promise<T> {
const originalCwd = process.cwd();
const tempDir = await mkdtemp(path.join(tmpdir(), "braintrust-otel-env-"));
try {
await writeFile(path.join(tempDir, ".env.braintrust"), "");
await writeFile(path.join(tempDir, ".env.braintrust"), content);
process.chdir(tempDir);
return fn();
} finally {
Expand All @@ -49,6 +55,12 @@ async function withEmptyBraintrustEnvFile<T>(
}
}

async function withEmptyBraintrustEnvFile<T>(
fn: () => T | Promise<T>,
): Promise<T> {
return withBraintrustEnvFile("", fn);
}

describe("AISpanProcessor", () => {
let memoryExporter: InMemorySpanExporter;
let provider: BasicTracerProvider;
Expand Down Expand Up @@ -178,6 +190,35 @@ describe("AISpanProcessor", () => {
expect(spans.find((s) => s.attributes["foo"] === "bar")).toBeUndefined();
});

it("should ignore Braintrust system attributes when filtering AI spans", async () => {
const rootSpan = tracer.startSpan("root");

const parentContext = trace.setSpanContext(
context.active(),
rootSpan.spanContext(),
);
const systemAttrSpan = tracer.startSpan(
"some.operation",
{
attributes: {
"braintrust.context_json": JSON.stringify({
span_origin: { name: "braintrust.sdk.javascript" },
}),
"braintrust.parent": "project_name:test",
},
},
parentContext,
);

systemAttrSpan.end();
rootSpan.end();

await provider.forceFlush();

const spans = memoryExporter.getFinishedSpans();
expect(spans.find((s) => s.name === "some.operation")).toBeUndefined();
});

it("should support custom filter that keeps root spans using isRootSpan", () => {
const customFilter = (span: ReadableSpan) => {
return isRootSpan(span) ? true : undefined;
Expand Down Expand Up @@ -754,6 +795,161 @@ describe("BraintrustSpanProcessor", () => {
await expect(processor.forceFlush()).resolves.toBeUndefined();
});

it("should merge span origin with context_json set after span start", () => {
let endedSpan: ReadableSpan | undefined;
const innerProcessor = {
onStart: vi.fn(),
onEnd: vi.fn((span: ReadableSpan) => {
endedSpan = span;
}),
shutdown: vi.fn(async () => undefined),
forceFlush: vi.fn(async () => undefined),
};

const processor = new BraintrustSpanProcessor({
apiKey: "test-api-key",
_spanProcessor: innerProcessor as any,
});

const mockSpan = {
spanContext: () => ({ traceId: "test-trace", spanId: "test-span" }),
setAttributes: vi.fn(),
name: "late-context",
attributes: {
"braintrust.context_json": JSON.stringify({
metadata: { source: "late-attribute" },
}),
},
parentSpanContext: undefined,
} as any;

processor.onStart(mockSpan, context.active());
processor.onEnd(mockSpan);

expect(endedSpan).toBeDefined();
const contextJson = endedSpan?.attributes["braintrust.context_json"];
expect(typeof contextJson).toBe("string");
const parsed = JSON.parse(contextJson as string);
expect(parsed.metadata.source).toBe("late-attribute");
expect(parsed.span_origin.name).toBe("braintrust.sdk.javascript");
expect(parsed.span_origin.version).toBe(packageJson.version);
expect(parsed.span_origin.instrumentation.name).toBe("braintrust-otel-js");
});

it("should preserve explicit environment name without type", () => {
process.env.BRAINTRUST_ENVIRONMENT_NAME = "staging";
delete process.env.BRAINTRUST_ENVIRONMENT_TYPE;

let endedSpan: ReadableSpan | undefined;
const innerProcessor = {
onStart: vi.fn(),
onEnd: vi.fn((span: ReadableSpan) => {
endedSpan = span;
}),
shutdown: vi.fn(async () => undefined),
forceFlush: vi.fn(async () => undefined),
};

const processor = new BraintrustSpanProcessor({
apiKey: "test-api-key",
_spanProcessor: innerProcessor as any,
});

const mockSpan = {
spanContext: () => ({ traceId: "test-trace", spanId: "test-span" }),
setAttributes: vi.fn(),
name: "environment-name-only",
attributes: {},
parentSpanContext: undefined,
} as any;

processor.onStart(mockSpan, context.active());
processor.onEnd(mockSpan);

const parsed = JSON.parse(
endedSpan?.attributes["braintrust.context_json"] as string,
);
expect(parsed.span_origin.environment).toEqual({ name: "staging" });
});

it("should read explicit environment name from .env.braintrust", async () => {
delete process.env.BRAINTRUST_ENVIRONMENT_NAME;
delete process.env.BRAINTRUST_ENVIRONMENT_TYPE;

await withBraintrustEnvFile(
"BRAINTRUST_ENVIRONMENT_NAME=staging\n",
async () => {
let endedSpan: ReadableSpan | undefined;
const innerProcessor = {
onStart: vi.fn(),
onEnd: vi.fn((span: ReadableSpan) => {
endedSpan = span;
}),
shutdown: vi.fn(async () => undefined),
forceFlush: vi.fn(async () => undefined),
};

const processor = new BraintrustSpanProcessor({
apiKey: "test-api-key",
_spanProcessor: innerProcessor as any,
});

const mockSpan = {
spanContext: () => ({ traceId: "test-trace", spanId: "test-span" }),
setAttributes: vi.fn(),
name: "environment-from-file",
attributes: {},
parentSpanContext: undefined,
} as any;

processor.onStart(mockSpan, context.active());
processor.onEnd(mockSpan);

const parsed = JSON.parse(
endedSpan?.attributes["braintrust.context_json"] as string,
);
expect(parsed.span_origin.environment).toEqual({ name: "staging" });
},
);
});

it("should preserve custom NODE_ENV values as environment names", () => {
process.env.NODE_ENV = "preview";
delete process.env.BRAINTRUST_ENVIRONMENT_NAME;
delete process.env.BRAINTRUST_ENVIRONMENT_TYPE;

let endedSpan: ReadableSpan | undefined;
const innerProcessor = {
onStart: vi.fn(),
onEnd: vi.fn((span: ReadableSpan) => {
endedSpan = span;
}),
shutdown: vi.fn(async () => undefined),
forceFlush: vi.fn(async () => undefined),
};

const processor = new BraintrustSpanProcessor({
apiKey: "test-api-key",
_spanProcessor: innerProcessor as any,
});

const mockSpan = {
spanContext: () => ({ traceId: "test-trace", spanId: "test-span" }),
setAttributes: vi.fn(),
name: "custom-node-env",
attributes: {},
parentSpanContext: undefined,
} as any;

processor.onStart(mockSpan, context.active());
processor.onEnd(mockSpan);

const parsed = JSON.parse(
endedSpan?.attributes["braintrust.context_json"] as string,
);
expect(parsed.span_origin.environment).toEqual({ name: "preview" });
});

it("should use default parent when none is provided", () => {
process.env.BRAINTRUST_API_KEY = "test-api-key";
delete process.env.BRAINTRUST_PARENT;
Expand Down
Loading
Loading