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/span-origin-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": minor

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the @braintrust/otel package to this changeset

This commit changes @braintrust/otel runtime behavior and adds an environment processor option, but the changeset only lists braintrust. Packages not listed in the changeset will not receive a version bump/release, so the new OTEL span provenance code described here would not be published to users of @braintrust/otel.

Useful? React with 👍 / 👎.

---

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
107 changes: 107 additions & 0 deletions integrations/otel-js/src/otel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "../tests/utils";
import { SpanComponentsV3, SpanComponentsV4 } from "braintrust/util";
import { setupOtelCompat, resetOtelCompat } from ".";
import packageJson from "../package.json";

async function withEmptyBraintrustEnvFile<T>(
fn: () => T | Promise<T>,
Expand Down Expand Up @@ -178,6 +179,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 +784,83 @@ 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 use default parent when none is provided", () => {
process.env.BRAINTRUST_API_KEY = "test-api-key";
delete process.env.BRAINTRUST_PARENT;
Expand Down
Loading
Loading