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
12 changes: 12 additions & 0 deletions .changeset/otel-span-customizers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"braintrust": minor
"@braintrust/otel": minor
---

feat: support OpenTelemetry span customizers

Run the existing synchronous `onSpanExport` registration on isolated plain OTel snapshots after filtering and origin enrichment. Preserve identity, timing and routing, honor content removal, and fail closed before OTLP serialization by stripping failed spans using the native pipeline's once-only safe diagnostics. Support public processors/exporters, compatibility mode, OTel 1.x/2.x, and ESM/CJS bundles without duplicate customization.

Keep resource snapshots isolated throughout the customizer chain so a later callback cannot mutate another span's export, including when the callback throws.

Deduplicate pending-resource waits within each customizer batch while preserving delayed attribute redaction and per-span snapshot isolation.
11 changes: 0 additions & 11 deletions integrations/otel-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,6 @@ function resetBraintrustCompatCaches() {
}

export const setupOtelCompat = () => {
// Read braintrust's shared registration directly so this works with any
// supported braintrust version. Keep the message in sync with braintrust.
const spanCustomizers: unknown = (globalThis as Record<symbol, unknown>)[
Symbol.for("braintrust.spanCustomizers")
];
if (Array.isArray(spanCustomizers) && spanCustomizers.length) {
// eslint-disable-next-line no-restricted-properties -- intentional always-visible configuration error.
console.error(
"Braintrust span customizers are not supported with OpenTelemetry compat mode yet.",
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
(globalThis as any).BRAINTRUST_CONTEXT_MANAGER = OtelContextManager;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
Expand Down
44 changes: 18 additions & 26 deletions integrations/otel-js/src/otel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { SpanComponentsV4, SpanObjectTypeV3 } from "braintrust/util";
import {
customizeOtelSpans,
isCustomizedOtelSpan,
otelCompatFields,
} from "./span-customizer";

import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import {
Expand Down Expand Up @@ -354,32 +359,19 @@ class LazyBraintrustOTLPTraceExporter implements SpanExporter {
resultCallback: (result: ExportResult) => void,
): void {
void this.getExporter()
.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;
.then(async (exporter) => {
// Customize at the export boundary: upstream processors have already
// filtered, enriched and dropped unsampled spans.
const customizedSpans = await customizeOtelSpans(spans);
const compatibleSpans = customizedSpans.map((span) => {
// Customized snapshots already carry both OTel 1.x and 2.x fields.
if (isCustomizedOtelSpan(span)) return span;
const {
instrumentationScope,
instrumentationLibrary,
parentSpanContext,
parentSpanId,
} = otelCompatFields(span);

return new Proxy(span, {
get(target, prop) {
Expand Down
Loading
Loading