diff --git a/integrations/otel-js/src/id-gen.test.ts b/integrations/otel-js/src/id-gen.test.ts index ec5366352..b38e0afbc 100644 --- a/integrations/otel-js/src/id-gen.test.ts +++ b/integrations/otel-js/src/id-gen.test.ts @@ -42,12 +42,57 @@ describe("ID Generation", () => { }); describe("getIdGenerator factory function", () => { - test("returns UUID generator by default", () => { + test("returns hex-id generator by default (after reset)", () => { + // The core SDK now defaults to OpenTelemetry-compatible hex ids even + // without compat installed, so resetting the compat globals leaves the + // hex default in place (BRAINTRUST_LEGACY_IDS opts back into UUIDs). + const prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + const prevOtel = process.env.BRAINTRUST_OTEL_COMPAT; + delete process.env.BRAINTRUST_LEGACY_IDS; + delete process.env.BRAINTRUST_OTEL_COMPAT; resetOtelCompat(); - const generator = getIdGenerator(); - expect(generator).toBeInstanceOf(UUIDGenerator); - expect(generator.shareRootSpanId()).toBe(true); + try { + const generator = getIdGenerator(); + expect(generator.shareRootSpanId()).toBe(false); + expect(/^[0-9a-f]{16}$/.test(generator.getSpanId())).toBe(true); + } finally { + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + if (prevOtel === undefined) { + delete process.env.BRAINTRUST_OTEL_COMPAT; + } else { + process.env.BRAINTRUST_OTEL_COMPAT = prevOtel; + } + } + }); + + test("returns UUID generator when BRAINTRUST_LEGACY_IDS is set", () => { + const prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + const prevOtel = process.env.BRAINTRUST_OTEL_COMPAT; + delete process.env.BRAINTRUST_OTEL_COMPAT; + process.env.BRAINTRUST_LEGACY_IDS = "true"; + resetOtelCompat(); + + try { + const generator = getIdGenerator(); + expect(generator).toBeInstanceOf(UUIDGenerator); + expect(generator.shareRootSpanId()).toBe(true); + } finally { + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + if (prevOtel === undefined) { + delete process.env.BRAINTRUST_OTEL_COMPAT; + } else { + process.env.BRAINTRUST_OTEL_COMPAT = prevOtel; + } + } }); test("returns OTEL generator when otel is initialized", () => { diff --git a/integrations/otel-js/src/otel.test.ts b/integrations/otel-js/src/otel.test.ts index 8941972b8..5f8d543d9 100644 --- a/integrations/otel-js/src/otel.test.ts +++ b/integrations/otel-js/src/otel.test.ts @@ -1393,26 +1393,38 @@ describe("Otel Compat tests Integration", () => { }); test("UUID generator should share span_id as root_span_id for backwards compatibility", async () => { - // Ensure UUID generator is used (default behavior) + // Legacy UUID mode (hex ids are the default now). resetOtelCompat(); + const prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + process.env.BRAINTRUST_LEGACY_IDS = "true"; + _exportsForTestingOnly.resetIdGenStateForTests(); - const testLogger = initLogger({ - projectName: "test-uuid-integration", - projectId: "test-project-id", - }); + try { + const testLogger = initLogger({ + projectName: "test-uuid-integration", + projectId: "test-project-id", + }); - const span = testLogger.startSpan({ name: "test-uuid-span" }); + const span = testLogger.startSpan({ name: "test-uuid-span" }); - // UUID generators should share span_id as root_span_id for backwards compatibility - expect(span.spanId).toBe(span.rootSpanId); + // UUID generators should share span_id as root_span_id for backwards compatibility + expect(span.spanId).toBe(span.rootSpanId); - // Verify UUID format (36 characters with dashes) - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - expect(span.spanId).toMatch(uuidRegex); - expect(span.rootSpanId).toMatch(uuidRegex); + // Verify UUID format (36 characters with dashes) + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(span.spanId).toMatch(uuidRegex); + expect(span.rootSpanId).toMatch(uuidRegex); - span.end(); + span.end(); + } finally { + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + _exportsForTestingOnly.resetIdGenStateForTests(); + } }); test("OTEL generator should not share span_id as root_span_id", async () => { @@ -1437,30 +1449,42 @@ describe("Otel Compat tests Integration", () => { test("parent-child relationships work with UUID generators", async () => { resetOtelCompat(); + const prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + process.env.BRAINTRUST_LEGACY_IDS = "true"; + _exportsForTestingOnly.resetIdGenStateForTests(); - const testLogger = initLogger({ - projectName: "test-uuid-parent-child", - projectId: "test-project-id", - }); + try { + const testLogger = initLogger({ + projectName: "test-uuid-parent-child", + projectId: "test-project-id", + }); - const parentSpan = testLogger.startSpan({ name: "uuid-parent" }); + const parentSpan = testLogger.startSpan({ name: "uuid-parent" }); - // Parent should have span_id === root_span_id - expect(parentSpan.spanId).toBe(parentSpan.rootSpanId); + // Parent should have span_id === root_span_id + expect(parentSpan.spanId).toBe(parentSpan.rootSpanId); - const childSpan = parentSpan.startSpan({ name: "uuid-child" }); + const childSpan = parentSpan.startSpan({ name: "uuid-child" }); - // Child should inherit parent's root_span_id - expect(childSpan.rootSpanId).toBe(parentSpan.rootSpanId); + // Child should inherit parent's root_span_id + expect(childSpan.rootSpanId).toBe(parentSpan.rootSpanId); - // Child should have parent in spanParents - expect(childSpan.spanParents).toContain(parentSpan.spanId); + // Child should have parent in spanParents + expect(childSpan.spanParents).toContain(parentSpan.spanId); - // Child should have its own span_id (different from parent) - expect(childSpan.spanId).not.toBe(parentSpan.spanId); + // Child should have its own span_id (different from parent) + expect(childSpan.spanId).not.toBe(parentSpan.spanId); - parentSpan.end(); - childSpan.end(); + parentSpan.end(); + childSpan.end(); + } finally { + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + _exportsForTestingOnly.resetIdGenStateForTests(); + } }); test("parent-child relationships work with OTEL generators", async () => { @@ -1496,51 +1520,62 @@ describe("Otel Compat tests Integration", () => { }); test("environment variable switching works correctly", async () => { - // Test default (UUID) - resetOtelCompat(); + const prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + try { + // Test legacy UUID mode. + resetOtelCompat(); + process.env.BRAINTRUST_LEGACY_IDS = "true"; + _exportsForTestingOnly.resetIdGenStateForTests(); + + const uuidLogger = initLogger({ + projectName: "test-env-uuid", + projectId: "test-project-id", + }); - const uuidLogger = initLogger({ - projectName: "test-env-uuid", - projectId: "test-project-id", - }); + const uuidSpan = uuidLogger.startSpan({ name: "uuid-test" }); + expect(uuidSpan.spanId).toBe(uuidSpan.rootSpanId); + expect(uuidSpan.spanId).toMatch(uuidRegex); + uuidSpan.end(); - const uuidSpan = uuidLogger.startSpan({ name: "uuid-test" }); - expect(uuidSpan.spanId).toBe(uuidSpan.rootSpanId); - expect(uuidSpan.spanId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ); - uuidSpan.end(); + // Switch to OTEL compat (hex, wins over legacy). + setupOtelCompat(); + _exportsForTestingOnly.resetIdGenStateForTests(); - // Switch to OTEL - setupOtelCompat(); - _exportsForTestingOnly.resetIdGenStateForTests(); + const otelLogger = initLogger({ + projectName: "test-env-otel", + projectId: "test-project-id", + }); - const otelLogger = initLogger({ - projectName: "test-env-otel", - projectId: "test-project-id", - }); + const otelSpan = otelLogger.startSpan({ name: "otel-test" }); + expect(otelSpan.spanId).not.toBe(otelSpan.rootSpanId); + expect(otelSpan.spanId.length).toBe(16); + expect(otelSpan.rootSpanId.length).toBe(32); + otelSpan.end(); - const otelSpan = otelLogger.startSpan({ name: "otel-test" }); - expect(otelSpan.spanId).not.toBe(otelSpan.rootSpanId); - expect(otelSpan.spanId.length).toBe(16); - expect(otelSpan.rootSpanId.length).toBe(32); - otelSpan.end(); + // Switch back to legacy UUID. + resetOtelCompat(); + process.env.BRAINTRUST_LEGACY_IDS = "true"; + _exportsForTestingOnly.resetIdGenStateForTests(); - // Switch back to UUID - resetOtelCompat(); - _exportsForTestingOnly.resetIdGenStateForTests(); - - const uuidLogger2 = initLogger({ - projectName: "test-env-uuid2", - apiKey: "test-key", - }); + const uuidLogger2 = initLogger({ + projectName: "test-env-uuid2", + apiKey: "test-key", + }); - const uuidSpan2 = uuidLogger2.startSpan({ name: "uuid-test2" }); - expect(uuidSpan2.spanId).toBe(uuidSpan2.rootSpanId); - expect(uuidSpan2.spanId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ); - uuidSpan2.end(); + const uuidSpan2 = uuidLogger2.startSpan({ name: "uuid-test2" }); + expect(uuidSpan2.spanId).toBe(uuidSpan2.rootSpanId); + expect(uuidSpan2.spanId).toMatch(uuidRegex); + uuidSpan2.end(); + } finally { + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + _exportsForTestingOnly.resetIdGenStateForTests(); + } }); test("case insensitive environment variable", async () => { @@ -1587,31 +1622,65 @@ describe("export() format selection based on if otel is initialized", () => { _exportsForTestingOnly.resetIdGenStateForTests(); }); - test("uses SpanComponentsV3 when otel is not initialized", async () => { + test("uses SpanComponentsV3 in legacy mode when otel is not initialized", async () => { resetOtelCompat(); + const prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + process.env.BRAINTRUST_LEGACY_IDS = "true"; + _exportsForTestingOnly.resetIdGenStateForTests(); + + try { + const testLogger = initLogger({ + projectName: "test-export-v3", + projectId: "test-project-id", + }); + const span = testLogger.startSpan({ name: "test-span" }); + + const exported = await span.export(); + expect(typeof exported).toBe("string"); + expect(exported.length).toBeGreaterThan(0); + + // Verify version byte is 3 (legacy UUID mode -> V3 export) + expect(getExportVersion(exported)).toBe(3); + + // The exported string should be parseable by both V3 and V4 (V4 can read V3) + const v3Components = SpanComponentsV3.fromStr(exported); + expect(v3Components.data.row_id).toBe(span.id); + expect(v3Components.data.span_id).toBe(span.spanId); + expect(v3Components.data.root_span_id).toBe(span.rootSpanId); + + // V4 should also be able to read V3 format + const v4Components = SpanComponentsV4.fromStr(exported); + expect(v4Components.data.row_id).toBe(span.id); + + span.end(); + } finally { + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + } + }); + + test("uses SpanComponentsV4 by default when otel is not initialized", async () => { + resetOtelCompat(); + _exportsForTestingOnly.resetIdGenStateForTests(); const testLogger = initLogger({ - projectName: "test-export-v3", + projectName: "test-export-v4-default", projectId: "test-project-id", }); const span = testLogger.startSpan({ name: "test-span" }); const exported = await span.export(); - expect(typeof exported).toBe("string"); - expect(exported.length).toBeGreaterThan(0); - - // Verify version byte is 3 - expect(getExportVersion(exported)).toBe(3); - - // The exported string should be parseable by both V3 and V4 (V4 can read V3) - const v3Components = SpanComponentsV3.fromStr(exported); - expect(v3Components.data.row_id).toBe(span.id); - expect(v3Components.data.span_id).toBe(span.spanId); - expect(v3Components.data.root_span_id).toBe(span.rootSpanId); + // The core SDK now defaults to V4 (OTEL-compatible hex ids) even without + // compat installed. + expect(getExportVersion(exported)).toBe(4); - // V4 should also be able to read V3 format const v4Components = SpanComponentsV4.fromStr(exported); expect(v4Components.data.row_id).toBe(span.id); + expect(v4Components.data.span_id).toBe(span.spanId); + expect(v4Components.data.root_span_id).toBe(span.rootSpanId); span.end(); }); @@ -1640,8 +1709,11 @@ describe("export() format selection based on if otel is initialized", () => { }); test("Logger.export() uses correct format based on env var", async () => { - // Test V3 + // Test V3 (legacy UUID mode) resetOtelCompat(); + const prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + process.env.BRAINTRUST_LEGACY_IDS = "true"; + _exportsForTestingOnly.resetIdGenStateForTests(); const loggerV3 = initLogger({ projectName: "test-logger-export-v3", @@ -1653,6 +1725,13 @@ describe("export() format selection based on if otel is initialized", () => { const v3Parsed = SpanComponentsV3.fromStr(exportedV3); expect(v3Parsed.data.object_type).toBeDefined(); + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + _exportsForTestingOnly.resetIdGenStateForTests(); + // Test V4 setupOtelCompat(); const loggerV4 = initLogger({ @@ -1727,40 +1806,50 @@ describe("export() format selection based on if otel is initialized", () => { span.end(); }); - test("V3 format uses UUIDs when otel is not initialized", async () => { + test("V3 format uses UUIDs in legacy mode when otel is not initialized", async () => { resetOtelCompat(); - + const prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + process.env.BRAINTRUST_LEGACY_IDS = "true"; _exportsForTestingOnly.resetIdGenStateForTests(); - const testLogger = initLogger({ - projectName: "test-uuid-ids", - projectId: "test-project-id", - }); + try { + const testLogger = initLogger({ + projectName: "test-uuid-ids", + projectId: "test-project-id", + }); - const span = testLogger.startSpan({ name: "test-span-uuid" }); + const span = testLogger.startSpan({ name: "test-span-uuid" }); - // Verify the span has UUID format (with dashes) - expect(span.spanId.length).toBe(36); // UUID format - expect(span.spanId).toContain("-"); - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - expect(span.spanId).toMatch(uuidRegex); + // Verify the span has UUID format (with dashes) + expect(span.spanId.length).toBe(36); // UUID format + expect(span.spanId).toContain("-"); + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(span.spanId).toMatch(uuidRegex); - // Export the span - const exported = await span.export(); + // Export the span + const exported = await span.export(); - // Parse the exported data with V3 - const parsed = SpanComponentsV3.fromStr(exported); + // Parse the exported data with V3 + const parsed = SpanComponentsV3.fromStr(exported); - // Verify the parsed data has the same UUID - expect(parsed.data.span_id).toBe(span.spanId); + // Verify the parsed data has the same UUID + expect(parsed.data.span_id).toBe(span.spanId); - // V3 uses UUID compression in binary format - const rawBytes = base64ToUint8Array(exported); + // V3 uses UUID compression in binary format + const rawBytes = base64ToUint8Array(exported); - // Check that version byte is 3 - expect(rawBytes[0]).toBe(3); + // Check that version byte is 3 + expect(rawBytes[0]).toBe(3); - span.end(); + span.end(); + } finally { + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + _exportsForTestingOnly.resetIdGenStateForTests(); + } }); }); diff --git a/js/src/edge-runtime-bootstrap.test.ts b/js/src/edge-runtime-bootstrap.test.ts index b3e81a2ac..b02186e47 100644 --- a/js/src/edge-runtime-bootstrap.test.ts +++ b/js/src/edge-runtime-bootstrap.test.ts @@ -105,7 +105,9 @@ describe.each([ expect(result.isNoop).toBe(false); expect(result.sameObject).toBe(true); expect(result.childParents).toHaveLength(1); - expect(result.childRootSpanId).toBe(root.spanId); + // With the default hex (OTEL-compatible) ids, root_span_id is the trace + // id shared across the trace, not the root span's own span id. + expect(result.childRootSpanId).toBe(root.rootSpanId); expect(await backgroundLogger.drain()).toHaveLength(3); }); diff --git a/js/src/exports.ts b/js/src/exports.ts index 54cd03e2a..ddf5fca0b 100644 --- a/js/src/exports.ts +++ b/js/src/exports.ts @@ -34,6 +34,7 @@ export type { MetricSummary, ObjectMetadata, PromiseUnless, + PropagationContext, PromptRowWithId, ScoreSummary, SerializedBraintrustState, @@ -85,11 +86,13 @@ export { currentSpan, deepCopyEvent, deserializePlainStringAsJSON, + extractTraceContext, flush, getContextManager, getPromptVersions, getSpanParentObject, init, + injectTraceContext, initDataset, initExperiment, initLogger, @@ -158,7 +161,20 @@ export { devNullWritableStream, } from "./functions/stream"; -export { IDGenerator, UUIDGenerator, getIdGenerator } from "./id-gen"; +export { + IDGenerator, + UUIDGenerator, + OTELIDGenerator, + getIdGenerator, +} from "./id-gen"; + +export { + TRACEPARENT_HEADER, + TRACESTATE_HEADER, + BAGGAGE_HEADER, + BRAINTRUST_PARENT_KEY, +} from "./propagation"; +export type { ParsedTraceparent, PropagatedState } from "./propagation"; export { LEGACY_CACHED_HEADER, diff --git a/js/src/framework.ts b/js/src/framework.ts index 4f0ec0c89..61041856c 100644 --- a/js/src/framework.ts +++ b/js/src/framework.ts @@ -4,7 +4,7 @@ import { Classification, ClassificationItem, Score, - SpanComponentsV3, + SpanComponentsV4, SpanTypeAttribute, spanObjectTypeV3ToTypedString, } from "../util/index"; @@ -1229,9 +1229,15 @@ async function runEvaluatorInternal( }; const parentStr = state.currentParent.getStore(); - const parentComponents = parentStr - ? SpanComponentsV3.fromStr(parentStr) - : null; + // The eval framework only ever sets a slug string here; a W3C + // trace-context object (from extractTraceContext) is not expected in + // this path, so it is treated as no parent. + // SpanComponentsV4.fromStr decodes both V4 (default) and older V3 + // slugs, so it works regardless of the active export version. + const parentComponents = + typeof parentStr === "string" + ? SpanComponentsV4.fromStr(parentStr) + : null; const trace = state ? new LocalTrace({ diff --git a/js/src/id-gen.test.ts b/js/src/id-gen.test.ts index 8d1d9f27e..2ea2ae85b 100644 --- a/js/src/id-gen.test.ts +++ b/js/src/id-gen.test.ts @@ -1,37 +1,117 @@ -import { expect, test, describe } from "vitest"; -import { UUIDGenerator } from "braintrust"; +import { expect, test, describe, beforeEach, afterEach } from "vitest"; +import { UUIDGenerator, OTELIDGenerator, getIdGenerator } from "./id-gen"; +import { configureNode } from "./node/config"; + +configureNode(); + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isHex(s: string): boolean { + return /^[0-9a-f]+$/.test(s); +} describe("ID Generation", () => { describe("UUIDGenerator", () => { test("implements IDGenerator interface and generates valid UUIDs", () => { const generator = new UUIDGenerator(); - // Test that UUID generators should share root_span_id for backwards compatibility + // UUID generators should share root_span_id for backwards compatibility expect(generator.shareRootSpanId()).toBe(true); - // Test span ID generation const spanId1 = generator.getSpanId(); const spanId2 = generator.getSpanId(); - expect(spanId1).not.toBe(spanId2); - expect(typeof spanId1).toBe("string"); - expect(typeof spanId2).toBe("string"); + expect(spanId1).toMatch(UUID_RE); + expect(spanId2).toMatch(UUID_RE); - // Validate UUID format (36 characters with dashes) - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - expect(spanId1).toMatch(uuidRegex); - expect(spanId2).toMatch(uuidRegex); - - // Test trace ID generation const traceId1 = generator.getTraceId(); const traceId2 = generator.getTraceId(); - expect(traceId1).not.toBe(traceId2); - expect(typeof traceId1).toBe("string"); - expect(typeof traceId2).toBe("string"); - expect(traceId1).toMatch(uuidRegex); - expect(traceId2).toMatch(uuidRegex); + expect(traceId1).toMatch(UUID_RE); + expect(traceId2).toMatch(UUID_RE); + }); + }); + + describe("OTELIDGenerator", () => { + test("generates W3C-shaped hex ids and does not share root span id", () => { + const generator = new OTELIDGenerator(); + + expect(generator.shareRootSpanId()).toBe(false); + + const spanId = generator.getSpanId(); + expect(spanId.length).toBe(16); // 8 bytes hex + expect(isHex(spanId)).toBe(true); + + const traceId = generator.getTraceId(); + expect(traceId.length).toBe(32); // 16 bytes hex + expect(isHex(traceId)).toBe(true); + }); + }); + + describe("getIdGenerator env selection", () => { + let prevLegacy: string | undefined; + let prevOtel: string | undefined; + + beforeEach(() => { + prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + prevOtel = process.env.BRAINTRUST_OTEL_COMPAT; + delete process.env.BRAINTRUST_LEGACY_IDS; + delete process.env.BRAINTRUST_OTEL_COMPAT; + }); + + afterEach(() => { + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + if (prevOtel === undefined) { + delete process.env.BRAINTRUST_OTEL_COMPAT; + } else { + process.env.BRAINTRUST_OTEL_COMPAT = prevOtel; + } + }); + + test("defaults to hex ids (no env vars)", () => { + const generator = getIdGenerator(); + expect(generator.shareRootSpanId()).toBe(false); + expect(isHex(generator.getSpanId())).toBe(true); + expect(isHex(generator.getTraceId())).toBe(true); + }); + + test.each(["true", "True", "TRUE", "1", "yes", "on"])( + "BRAINTRUST_OTEL_COMPAT=%s -> hex", + (value) => { + process.env.BRAINTRUST_OTEL_COMPAT = value; + const generator = getIdGenerator(); + expect(isHex(generator.getSpanId())).toBe(true); + expect(generator.shareRootSpanId()).toBe(false); + }, + ); + + test.each(["true", "True", "1"])( + "BRAINTRUST_LEGACY_IDS=%s -> UUID", + (value) => { + process.env.BRAINTRUST_LEGACY_IDS = value; + const generator = getIdGenerator(); + expect(generator.getSpanId()).toMatch(UUID_RE); + expect(generator.shareRootSpanId()).toBe(true); + }, + ); + + test("BRAINTRUST_LEGACY_IDS=false -> hex", () => { + process.env.BRAINTRUST_LEGACY_IDS = "false"; + const generator = getIdGenerator(); + expect(isHex(generator.getSpanId())).toBe(true); + }); + + test("OTEL_COMPAT wins over LEGACY_IDS when both set", () => { + process.env.BRAINTRUST_OTEL_COMPAT = "true"; + process.env.BRAINTRUST_LEGACY_IDS = "true"; + const generator = getIdGenerator(); + expect(generator.shareRootSpanId()).toBe(false); + expect(isHex(generator.getSpanId())).toBe(true); }); }); }); diff --git a/js/src/id-gen.ts b/js/src/id-gen.ts index 31f2b1659..4904d22da 100644 --- a/js/src/id-gen.ts +++ b/js/src/id-gen.ts @@ -1,7 +1,13 @@ // ID generation system for Braintrust spans -// Supports both UUID and OpenTelemetry-compatible ID formats +// Supports both UUID and OpenTelemetry-compatible (hex) ID formats. +// +// By default the SDK generates OpenTelemetry-compatible hex IDs (16-byte trace +// id / 8-byte span id) which can be propagated via W3C Trace Context. Setting +// BRAINTRUST_LEGACY_IDS opts back into the legacy UUID-based IDs. import { v4 as uuidv4 } from "uuid"; +import { debugLogger } from "./debug-logger"; +import iso from "./isomorph"; /** * Abstract base class for ID generators @@ -40,14 +46,104 @@ export class UUIDGenerator extends IDGenerator { } } +function generateHexId(bytes: number): string { + let result = ""; + for (let i = 0; i < bytes; i++) { + result += Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, "0"); + } + return result; +} + +/** + * ID generator that produces OpenTelemetry-compatible hex IDs. + * + * Span IDs are 8 random bytes (16 hex chars) and trace IDs are 16 random bytes + * (32 hex chars), matching the W3C Trace Context / OpenTelemetry shape. Trace + * ids are distinct from span ids (root_span_id is a separate trace id, not the + * root span's id), so `shareRootSpanId()` returns false. + */ +export class OTELIDGenerator extends IDGenerator { + getSpanId(): string { + // Generate 8 random bytes and convert to hex (16 characters) + return generateHexId(8); + } + + getTraceId(): string { + // Generate 16 random bytes and convert to hex (32 characters) + return generateHexId(16); + } + + shareRootSpanId(): boolean { + return false; + } +} + +/** + * Parse a boolean environment variable. Accepts common truthy/falsey spellings; + * unset or unrecognized values are treated as false. + */ +export function parseEnvBool(name: string): boolean { + const raw = iso.getEnv(name); + if (raw === undefined || raw === null) { + return false; + } + const normalized = raw.trim().toLowerCase(); + return ( + normalized === "true" || + normalized === "1" || + normalized === "yes" || + normalized === "y" || + normalized === "on" + ); +} + +let _warnedLegacyUuidConflict = false; + +/** + * Resolve whether the SDK should generate legacy UUID-based span/trace IDs. + * + * The default is OpenTelemetry-compatible hex IDs (16-byte trace id / 8-byte + * span id) with V4 span-component export. Setting BRAINTRUST_LEGACY_IDS opts + * back into UUID IDs with V3 export. + * + * BRAINTRUST_OTEL_COMPAT (which selects the OpenTelemetry context manager) + * requires hex IDs, so it always wins: if both it and BRAINTRUST_LEGACY_IDS are + * set, legacy IDs are disabled and a warning is logged (at most once per + * process, even though this is re-resolved lazily on each access). + */ +export function resolveUseLegacyUuidIds(): boolean { + const legacy = parseEnvBool("BRAINTRUST_LEGACY_IDS"); + if (parseEnvBool("BRAINTRUST_OTEL_COMPAT")) { + if (legacy && !_warnedLegacyUuidConflict) { + _warnedLegacyUuidConflict = true; + debugLogger.warn( + "BRAINTRUST_LEGACY_IDS is ignored because BRAINTRUST_OTEL_COMPAT " + + "requires OpenTelemetry-compatible hex span IDs. Using hex IDs.", + ); + } + return false; + } + return legacy; +} + /** * Factory function that creates a new ID generator instance each time. * * This eliminates global state and makes tests parallelizable. * Each caller gets their own generator instance. + * + * Honors an explicitly-installed `globalThis.BRAINTRUST_ID_GENERATOR` (e.g. set + * by `@braintrust/otel`'s `setupOtelCompat()`). Otherwise it defaults to + * OpenTelemetry-compatible hex IDs, falling back to legacy UUID IDs when + * BRAINTRUST_LEGACY_IDS is set. */ export function getIdGenerator(): IDGenerator { - return globalThis.BRAINTRUST_ID_GENERATOR !== undefined - ? new globalThis.BRAINTRUST_ID_GENERATOR() - : new UUIDGenerator(); + if (globalThis.BRAINTRUST_ID_GENERATOR !== undefined) { + return new globalThis.BRAINTRUST_ID_GENERATOR(); + } + return resolveUseLegacyUuidIds() + ? new UUIDGenerator() + : new OTELIDGenerator(); } diff --git a/js/src/logger.test.ts b/js/src/logger.test.ts index 7a5e12b70..9e862b833 100644 --- a/js/src/logger.test.ts +++ b/js/src/logger.test.ts @@ -25,7 +25,7 @@ import { type GitMetadataSettingsType as GitMetadataSettings } from "./generated import { writeFile, unlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { SpanComponentsV3 } from "../util/span_identifier_v3"; +import { SpanComponentsV4 } from "../util/span_identifier_v4"; configureNode(); @@ -2020,7 +2020,10 @@ test("startSpan support ids without parent", () => { const logger = initLogger({}); const span = logger.startSpan({ name: "test-span", spanId: "123" }); expect(span.spanId).toBe("123"); - expect(span.rootSpanId).toBe("123"); + // With the default hex (OTEL-compatible) ids, a root span gets a distinct + // trace id rather than reusing its span id, so root_span_id !== span_id. + expect(span.rootSpanId).not.toBe("123"); + expect(span.rootSpanId.length).toBe(32); // 16-byte hex trace id expect(span.spanParents).toEqual([]); span.end(); }); @@ -2862,8 +2865,8 @@ describe("sensitive data redaction", () => { expect(typeof exported).toBe("string"); expect(exported.length).toBeGreaterThan(0); - // The exported string should be parseable by SpanComponentsV3 - const components = SpanComponentsV3.fromStr(exported); + // The default export is now V4 (OTEL-compatible hex ids). + const components = SpanComponentsV4.fromStr(exported); expect(components.data.row_id).toBe(span.id); expect(components.data.span_id).toBe(span.spanId); expect(components.data.root_span_id).toBe(span.rootSpanId); diff --git a/js/src/logger.ts b/js/src/logger.ts index f81200f66..42825697e 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -13,7 +13,19 @@ import { setDebugLogStateResolver, setGlobalDebugLogLevel, } from "./debug-logger"; -import { IDGenerator, getIdGenerator } from "./id-gen"; +import { IDGenerator, getIdGenerator, resolveUseLegacyUuidIds } from "./id-gen"; +import { + BAGGAGE_HEADER, + BRAINTRUST_PARENT_KEY, + PropagatedState, + TRACEPARENT_HEADER, + TRACESTATE_HEADER, + formatTraceparent, + getHeader, + mergeBaggage, + parseBaggage, + parseTraceparent, +} from "./propagation"; import { _urljoin, AnyDatasetRecord, @@ -39,6 +51,7 @@ import { SanitizedExperimentLogPartialArgs, SpanComponentsV3, SpanComponentsV4, + SpanComponentsV4Data, SpanObjectTypeV3, spanObjectTypeV3ToString, SpanType, @@ -253,7 +266,12 @@ export type StartSpanArgs = { // eslint-disable-next-line @typescript-eslint/no-explicit-any spanAttributes?: Record; startTime?: number; - parent?: string; + /** + * The parent to start this span under. May be an exported span slug string + * (from `span.export()`) or an opaque W3C trace-context (from + * {@link extractTraceContext}). + */ + parent?: string | PropagationContext; event?: StartSpanEventArgs; propagatedEvent?: StartSpanEventArgs; spanId?: string; @@ -374,6 +392,22 @@ export interface Span extends Exportable { */ export(): Promise; + /** + * Inject W3C trace-context headers (`traceparent` and `baggage`) for this span + * into a carrier, for distributed tracing across service boundaries. + * + * Adds `traceparent` (trace identity) and, when this span's Braintrust parent + * is known, a `baggage` entry `braintrust.parent=` (merged with any + * pre-existing baggage). Propagation is best-effort and never throws; if the + * span's ids are not W3C-shaped hex (e.g. legacy UUID mode), `traceparent` is + * omitted. + * + * @param carrier Optional existing carrier (e.g. outbound HTTP headers) to + * mutate and return. A new object is created if not provided. + * @returns The carrier with propagation headers injected. + */ + inject(carrier?: Record): Record; + /** * Format a permalink to the Braintrust application for viewing this span. * @@ -508,10 +542,18 @@ declare global { type SpanComponent = typeof SpanComponentsV3 | typeof SpanComponentsV4; +// Return the active span-component exporter class. +// +// The export version is coupled to the active ID format: hex IDs (the default) +// serialize as V4, legacy UUID IDs serialize as V3. These must move together -- +// serializing hex IDs via V3 would lose the compact encoding and risk +// corrupting hex values that happen to parse as UUIDs. An explicit +// `globalThis.BRAINTRUST_SPAN_COMPONENT` (e.g. from `@braintrust/otel`) wins. function getSpanComponentsClass(): SpanComponent { - return globalThis.BRAINTRUST_SPAN_COMPONENT - ? globalThis.BRAINTRUST_SPAN_COMPONENT - : SpanComponentsV3; + if (globalThis.BRAINTRUST_SPAN_COMPONENT) { + return globalThis.BRAINTRUST_SPAN_COMPONENT; + } + return resolveUseLegacyUuidIds() ? SpanComponentsV3 : SpanComponentsV4; } export function getContextManager(): ContextManager { @@ -565,6 +607,10 @@ export class NoopSpan implements Span { return ""; } + public inject(carrier?: Record): Record { + return carrier ?? {}; + } + public async permalink(): Promise { return NOOP_SPAN_PERMALINK; } @@ -645,7 +691,7 @@ export class BraintrustState { // Note: the value of IsAsyncFlush doesn't really matter here, since we // (safely) dynamically cast it whenever retrieving the logger. public currentLogger: Logger | undefined; - public currentParent: IsoAsyncLocalStorage; + public currentParent: IsoAsyncLocalStorage; public currentSpan: IsoAsyncLocalStorage; // Any time we re-log in, we directly update the apiConn inside the logger. // This is preferable to replacing the whole logger, which would create the @@ -682,7 +728,9 @@ export class BraintrustState { this.id = `${new Date().toLocaleString()}-${stateNonce++}`; // This is for debugging. uuidv4() breaks on platforms like Cloudflare. this.currentExperiment = undefined; this.currentLogger = undefined; - this.currentParent = iso.newAsyncLocalStorage(); + this.currentParent = iso.newAsyncLocalStorage< + string | PropagationContext + >(); this.currentSpan = iso.newAsyncLocalStorage(); if (loginParams.fetch) { @@ -2047,7 +2095,7 @@ export function updateSpan({ > & OptionalStateArg): void { const resolvedState = state ?? _globalState; - const components = getSpanComponentsClass().fromStr(exported); + const components = SpanComponentsV4.fromStr(exported); if (!components.data.row_id) { throw new Error("Exported span must have a row id"); @@ -2066,6 +2114,15 @@ export function updateSpan({ }); } +/** + * An opaque W3C trace-context, as returned by {@link extractTraceContext}. + * + * Carries the relevant W3C headers (`traceparent`, `baggage`, `tracestate`). + * Callers MUST treat it as opaque and pass it straight to + * `startSpan({ parent })`. + */ +export type PropagationContext = Record; + interface ParentSpanIds { spanId: string; rootSpanId: string; @@ -2215,7 +2272,7 @@ export async function permalink( }; try { - const components = getSpanComponentsClass().fromStr(slug); + const components = SpanComponentsV4.fromStr(slug); const object_type = spanObjectTypeV3ToString(components.data.object_type); const [orgName, appUrl, object_id] = await Promise.all([ getOrgName(), @@ -2242,28 +2299,35 @@ export async function permalink( // the original argument set. function startSpanParentArgs(args: { state: BraintrustState; - parent: string | undefined; + // `parent` may be an exported slug string or an opaque W3C trace-context + // (from `extractTraceContext`). + parent: string | PropagationContext | undefined; parentObjectType: SpanObjectTypeV3; parentObjectId: LazyValue; parentComputeObjectMetadataArgs: Record | undefined; parentSpanIds: ParentSpanIds | MultiParentSpanIds | undefined; propagatedEvent: StartSpanEventArgs | undefined; + propagatedState?: PropagatedState | undefined; }): { parentObjectType: SpanObjectTypeV3; parentObjectId: LazyValue; parentComputeObjectMetadataArgs: Record | undefined; parentSpanIds: ParentSpanIds | MultiParentSpanIds | undefined; propagatedEvent: StartSpanEventArgs | undefined; + propagatedState: PropagatedState | undefined; } { let argParentObjectId: LazyValue | undefined = undefined; let argParentSpanIds: ParentSpanIds | MultiParentSpanIds | undefined = undefined; let argPropagatedEvent: StartSpanEventArgs | undefined = undefined; - if (args.parent) { + let argPropagatedState: PropagatedState | undefined = undefined; + const { parentSlug, propagatedState: parentPropagatedState } = + normalizeParent(args.parent, args.state); + if (parentSlug) { if (args.parentSpanIds) { throw new Error("Cannot specify both parent and parentSpanIds"); } - const parentComponents = getSpanComponentsClass().fromStr(args.parent); + const parentComponents = SpanComponentsV4.fromStr(parentSlug); if (args.parentObjectType !== parentComponents.data.object_type) { throw new Error( `Mismatch between expected span parent object type ${args.parentObjectType} and provided type ${parentComponents.data.object_type}`, @@ -2271,7 +2335,13 @@ function startSpanParentArgs(args: { } argParentObjectId = args.parentObjectId; - if (parentComponents.data.row_id) { + if ( + parentComponents.data.row_id && + parentSpanIdsUsable( + parentComponents.data.span_id, + parentComponents.data.root_span_id, + ) + ) { argParentSpanIds = { spanId: parentComponents.data.span_id, rootSpanId: parentComponents.data.root_span_id, @@ -2282,10 +2352,12 @@ function startSpanParentArgs(args: { ((parentComponents.data.propagated_event ?? undefined) as | StartSpanEventArgs | undefined); + argPropagatedState = args.propagatedState ?? parentPropagatedState; } else { argParentObjectId = args.parentObjectId; argParentSpanIds = args.parentSpanIds; argPropagatedEvent = args.propagatedEvent; + argPropagatedState = args.propagatedState; } return { @@ -2294,6 +2366,7 @@ function startSpanParentArgs(args: { parentComputeObjectMetadataArgs: args.parentComputeObjectMetadataArgs, parentSpanIds: argParentSpanIds, propagatedEvent: argPropagatedEvent, + propagatedState: argPropagatedState, }; } @@ -2536,6 +2609,24 @@ export class Logger implements Exportable { public _getLinkBaseUrl(): string | null { return _getLinkBaseUrl(this.state, this._linkArgs); } + + /** + * Return this logger's Braintrust parent string (`project_id:` or + * `project_name:`) for the `braintrust.parent` baggage entry, or + * undefined when it cannot be determined synchronously. + */ + public _getOtelParent(): string | undefined { + const id = + this.computeMetadataArgs?.project_id || this.lazyId.getSync().value; + if (id) { + return `project_id:${id}`; + } + const name = this.computeMetadataArgs?.project_name; + if (name) { + return `project_name:${name}`; + } + return undefined; + } } function castLogger( @@ -5153,38 +5244,410 @@ export function currentSpan(options?: OptionalStateArg): Span { return state.contextManager.getCurrentSpan() ?? NOOP_SPAN; } +/** + * Resolve the parent object and any forwarded W3C state in a single pass. + * + * Same precedence as {@link getSpanParentObject}, but also returns the W3C state + * (tracestate + raw traceparent flags) recovered while normalizing the `parent` + * argument, so callers don't have to re-run `normalizeParent` (which would + * re-parse baggage and re-resolve the active logger/experiment, potentially + * disagreeing if state changed between calls). The state is only meaningful when + * a parent slug was resolved; otherwise it is undefined. + */ +function getSpanParentObjectAndPropagatedState( + options?: AsyncFlushArg & + OptionalStateArg & { parent?: string | PropagationContext }, +): { + parentObject: + | SpanComponentsV3 + | SpanComponentsV4 + | Span + | Experiment + | Logger; + propagatedState: PropagatedState | undefined; +} { + const state = options?.state ?? _globalState; + + const parentSpan = currentSpan({ state }); + if (!Object.is(parentSpan, NOOP_SPAN)) { + return { parentObject: parentSpan, propagatedState: undefined }; + } + + const parent = options?.parent ?? state.currentParent.getStore(); + const { parentSlug, propagatedState } = normalizeParent(parent, state); + if (parentSlug) { + return { + parentObject: SpanComponentsV4.fromStr(parentSlug), + propagatedState, + }; + } + + const experiment = currentExperiment(); + if (experiment) { + return { parentObject: experiment, propagatedState: undefined }; + } + const logger = currentLogger(options); + if (logger) { + return { parentObject: logger, propagatedState: undefined }; + } + return { parentObject: NOOP_SPAN, propagatedState: undefined }; +} + /** * Mainly for internal use. Return the parent object for starting a span in a global context. - * Applies precedence: current span > propagated parent string > experiment > logger. + * Applies precedence: current span > propagated parent > experiment > logger. + * + * `parent` may be an exported slug string or an opaque W3C trace-context (from + * {@link extractTraceContext}). */ export function getSpanParentObject( options?: AsyncFlushArg & - OptionalStateArg & { parent?: string }, + OptionalStateArg & { parent?: string | PropagationContext }, ): | SpanComponentsV3 | SpanComponentsV4 | Span | Experiment | Logger { - const state = options?.state ?? _globalState; - - const parentSpan = currentSpan({ state }); - if (!Object.is(parentSpan, NOOP_SPAN)) { - return parentSpan; - } + return getSpanParentObjectAndPropagatedState(options).parentObject; +} - const parentStr = options?.parent ?? state.currentParent.getStore(); - if (parentStr) return getSpanComponentsClass().fromStr(parentStr); +/** + * Return the Braintrust parent string for the current logger/experiment, if any. + * + * Used as the fallback Braintrust parent on receive, when an inbound request + * carries trace identity (`traceparent`) but no `braintrust.parent` baggage. + */ +function currentBraintrustParent(state?: BraintrustState): string | undefined { + const resolvedState = state ?? _globalState; - const experiment = currentExperiment(); + const experiment = currentExperiment({ state: resolvedState }); if (experiment) { - return experiment; + try { + return experiment._getOtelParent() ?? undefined; + } catch { + return undefined; + } } - const logger = currentLogger(options); + + const logger = currentLogger({ state: resolvedState }); if (logger) { - return logger; + try { + return logger._getOtelParent() ?? undefined; + } catch { + return undefined; + } + } + + return undefined; +} + +interface BraintrustParentComponents { + objectType: SpanObjectTypeV3; + objectId: string | undefined; + computeArgs: Record | undefined; +} + +/** + * Parse a `braintrust.parent` string into object type / id / compute args. + * + * Accepts `project_id:`, `project_name:`, or `experiment_id:`. + * Returns undefined if the value is empty or malformed. + */ +function braintrustParentToComponents( + braintrustParent: string | undefined | null, +): BraintrustParentComponents | undefined { + if (!braintrustParent) { + return undefined; + } + if (braintrustParent.startsWith("project_id:")) { + const objectId = braintrustParent.slice("project_id:".length); + return objectId + ? { + objectType: SpanObjectTypeV3.PROJECT_LOGS, + objectId, + computeArgs: undefined, + } + : undefined; + } + if (braintrustParent.startsWith("project_name:")) { + const name = braintrustParent.slice("project_name:".length); + return name + ? { + objectType: SpanObjectTypeV3.PROJECT_LOGS, + objectId: undefined, + computeArgs: { project_name: name }, + } + : undefined; + } + if (braintrustParent.startsWith("experiment_id:")) { + const objectId = braintrustParent.slice("experiment_id:".length); + return objectId + ? { + objectType: SpanObjectTypeV3.EXPERIMENT, + objectId, + computeArgs: undefined, + } + : undefined; } - return NOOP_SPAN; + return undefined; +} + +/** + * Set a W3C trace-context header on a carrier, sending the lowercase name. + * + * Per the W3C Trace Context spec (§3.2.1 / §3.3.1), vendors SHOULD send these + * header names in lowercase. `name` is always the canonical lowercase key. A + * plain object carrier is case-sensitive, so any pre-existing case-variant (e.g. + * `Baggage` from a framework that title-cases headers) must be removed first, + * otherwise the carrier would end up with two conflicting headers. + */ +function setHeader( + carrier: Record, + name: string, + value: string, +): void { + const lowered = name.toLowerCase(); + for (const key of Object.keys(carrier)) { + if (key !== name && key.toLowerCase() === lowered) { + delete carrier[key]; + } + } + carrier[name] = value; +} + +/** + * Inject W3C trace-context headers into a carrier (in place). + * + * Emits `traceparent` from the hex trace/span ids, merges `braintrust.parent` + * into existing `baggage` when known, and forwards any inbound W3C state + * (`tracestate` plus the original `traceparent` trace-flags) carried in + * `propagatedState`. Pre-existing, non-Braintrust baggage entries are preserved. + */ +export function _injectIntoCarrier( + carrier: Record, + args: { + traceId: string | undefined; + spanId: string | undefined; + braintrustParent: string | undefined | null; + propagatedState?: PropagatedState | undefined; + }, +): void { + // Re-emit the inbound trace-flags verbatim so the upstream sampling decision + // (and any future flag bits) is preserved; defaults to sampled when we + // originated the trace (no inbound flags). + const traceFlags = args.propagatedState?.traceFlags; + const traceparent = traceFlags + ? formatTraceparent(args.traceId, args.spanId, traceFlags) + : formatTraceparent(args.traceId, args.spanId); + if (traceparent === undefined) { + // Ids aren't W3C-shaped (e.g. legacy UUID mode); nothing to propagate. + return; + } + setHeader(carrier, TRACEPARENT_HEADER, traceparent); + + // Forward upstream tracestate (per W3C, only alongside a valid traceparent). + const tracestate = args.propagatedState?.tracestate; + if (tracestate) { + setHeader(carrier, TRACESTATE_HEADER, tracestate); + } + + // Merge braintrust.parent into any existing baggage. Other vendors' members + // are forwarded byte-for-byte (see mergeBaggage) so we never rewrite their + // percent-encoding. + const existing = getHeader(carrier, BAGGAGE_HEADER); + const baggageValue = mergeBaggage(existing, args.braintrustParent); + if (baggageValue !== undefined) { + setHeader(carrier, BAGGAGE_HEADER, baggageValue); + } +} + +/** + * Inject W3C trace-context headers for the current (or given) span into a + * carrier. + * + * This is the free-function form of {@link Span.inject}, and the send-side + * counterpart of {@link extractTraceContext}. If no span is provided, the + * currently-active span is used. Propagation is best-effort and never throws. + * + * @param carrier Optional carrier (e.g. outbound HTTP headers) to mutate. + * @param options.span Optional span to inject. Defaults to the current span. + * @returns The carrier with propagation headers injected. + */ +export function injectTraceContext( + carrier?: Record, + options?: OptionalStateArg & { span?: Span }, +): Record { + const resolvedCarrier = carrier ?? {}; + const span = options?.span ?? currentSpan({ state: options?.state }); + try { + return span.inject(resolvedCarrier); + } catch (e) { + debugLogger.warn(`Error injecting trace context: ${e}`); + return resolvedCarrier; + } +} + +/** + * Extract an opaque W3C trace-context from inbound request headers. + * + * This is the receive-side counterpart of {@link Span.inject} / + * {@link injectTraceContext}. The return value is an opaque propagation context + * that can be passed as `parent` to `startSpan`: + * + * ```ts + * const ctx = extractTraceContext(request.headers); + * traced((span) => { ... }, { name: "handler", parent: ctx }); + * ``` + * + * Only the W3C Trace Context headers are interpreted (`traceparent`, `baggage`, + * `tracestate`); header lookups are case-insensitive. If no valid `traceparent` + * is present, returns undefined (the caller starts a fresh root span). The + * Braintrust container the trace is routed under is resolved when the span is + * created: from the `braintrust.parent` baggage entry, or else the + * currently-active logger/experiment. + * + * Callers should treat the return value as opaque. + * + * @param headers Inbound request headers (e.g. an HTTP framework's headers). + * @returns An opaque context for `startSpan({ parent })`, or undefined. + */ +export function extractTraceContext( + headers: Record | null | undefined, +): PropagationContext | undefined { + if (!headers) { + return undefined; + } + + const traceparent = getHeader(headers, TRACEPARENT_HEADER); + if (!traceparent || parseTraceparent(traceparent) === undefined) { + return undefined; + } + + const context: PropagationContext = { [TRACEPARENT_HEADER]: traceparent }; + const baggageValue = getHeader(headers, BAGGAGE_HEADER); + if (baggageValue) { + context[BAGGAGE_HEADER] = baggageValue; + } + const tracestate = getHeader(headers, TRACESTATE_HEADER); + if (tracestate) { + context[TRACESTATE_HEADER] = tracestate; + } + return context; +} + +/** + * Resolve a W3C trace-context into `{ parentSlug, propagatedState }`. + * + * Reads `traceparent` for trace identity and `braintrust.parent` from `baggage` + * (falling back to the currently-active logger/experiment) for routing, and + * builds an internal Braintrust parent slug. Captures the `tracestate` and raw + * `traceparent` flags to forward onward. Returns `{ undefined, undefined }` if + * the context cannot be resolved into a usable parent (so the caller falls back + * to local precedence / a fresh root). + */ +function resolveW3cParent( + context: PropagationContext, + state?: BraintrustState, +): { + parentSlug: string | undefined; + propagatedState: PropagatedState | undefined; +} { + const traceparent = getHeader(context, TRACEPARENT_HEADER); + const parsed = traceparent ? parseTraceparent(traceparent) : undefined; + if (parsed === undefined) { + return { parentSlug: undefined, propagatedState: undefined }; + } + const { traceId, spanId, traceFlags } = parsed; + + // Determine the Braintrust container: baggage -> current logger/experiment. + let braintrustParent: string | undefined = undefined; + const baggageValue = getHeader(context, BAGGAGE_HEADER); + if (baggageValue) { + braintrustParent = parseBaggage(baggageValue)[BRAINTRUST_PARENT_KEY]; + } + if (!braintrustParent) { + braintrustParent = currentBraintrustParent(state); + } + if (!braintrustParent) { + debugLogger.warn( + "Received traceparent without a braintrust.parent and no active logger/experiment; " + + "cannot route the trace. Starting a fresh local span instead.", + ); + return { parentSlug: undefined, propagatedState: undefined }; + } + + const parsedParent = braintrustParentToComponents(braintrustParent); + if (parsedParent === undefined) { + debugLogger.warn( + `Invalid braintrust.parent: ${JSON.stringify(braintrustParent)}`, + ); + return { parentSlug: undefined, propagatedState: undefined }; + } + const { objectType, objectId, computeArgs } = parsedParent; + + const tracestate = getHeader(context, TRACESTATE_HEADER) || undefined; + + const slug = new SpanComponentsV4({ + object_type: objectType, + ...(computeArgs + ? { compute_object_metadata_args: computeArgs } + : { object_id: objectId }), + row_id: "bt-propagation", // non-empty to enable span_id/root_span_id + span_id: spanId, + root_span_id: traceId, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + } as SpanComponentsV4Data).toStr(); + return { + parentSlug: slug, + propagatedState: { tracestate, traceFlags }, + }; +} + +/** + * Normalize a `parent` argument into `{ parentSlug, propagatedState }`. + * + * - object -> interpreted as a W3C trace-context (from `extractTraceContext`) + * - string -> an exported span slug (passed through unchanged) + * - undefined -> no parent + * + * `propagatedState` carries any inbound W3C state (tracestate + raw flags) to + * forward on the next `inject`; it is undefined when there is no inbound W3C + * context. + */ +function normalizeParent( + parent: string | PropagationContext | undefined | null, + state?: BraintrustState, +): { + parentSlug: string | undefined; + propagatedState: PropagatedState | undefined; +} { + if (parent && typeof parent === "object") { + return resolveW3cParent(parent, state); + } + return { + parentSlug: parent ?? undefined, + propagatedState: undefined, + }; +} + +/** + * Return true if a parent slug carries span ids the child can link to. + * + * A child links to any parent slug that carries both a span id and a root span + * id, regardless of whether those ids are hex (the default) or legacy UUID. This + * keeps the deprecated `startSpan({ parent: })` path backwards + * compatible: a slug exported by an older (UUID) sender still links into a trace + * on a newer (hex) receiver, and vice versa. The child's own freshly generated + * span id stays in the active format; the backend supports traces whose nodes + * mix id formats across a propagation boundary. Empty ids (no row) are handled + * by the caller. + */ +function parentSpanIdsUsable( + spanId: string | undefined | null, + rootSpanId: string | undefined | null, +): boolean { + return Boolean(spanId) && Boolean(rootSpanId); } export function logError(span: Span, error: unknown) { @@ -5561,25 +6024,37 @@ function startSpanAndIsLogger( ): { span: Span; isSyncFlushLogger: boolean } { const state = args?.state ?? _globalState; - const parentObject = getSpanParentObject({ - asyncFlush: args?.asyncFlush, - parent: args?.parent, - state, - }); + // Resolve the parent object and any forwarded W3C state in one pass, so we + // don't re-normalize `parent` (which could disagree if the active + // logger/experiment changed between calls). + const { parentObject, propagatedState } = + getSpanParentObjectAndPropagatedState({ + asyncFlush: args?.asyncFlush, + parent: args?.parent, + state, + }); if ( parentObject instanceof SpanComponentsV3 || parentObject instanceof SpanComponentsV4 ) { - const parentSpanIds: ParentSpanIds | undefined = parentObject.data.row_id - ? { - spanId: parentObject.data.span_id, - rootSpanId: parentObject.data.root_span_id, - } - : undefined; + const parentSpanIds: ParentSpanIds | undefined = + parentObject.data.row_id && + parentSpanIdsUsable( + parentObject.data.span_id, + parentObject.data.root_span_id, + ) + ? { + spanId: parentObject.data.span_id, + rootSpanId: parentObject.data.root_span_id, + } + : undefined; + // The parent object/state are already resolved from `parent` above; drop + // the raw `parent` so it isn't re-normalized. + const { parent: _ignoredParent, ...spanArgs } = args ?? {}; const span = new SpanImpl({ state, - ...args, + ...spanArgs, parentObjectType: parentObject.data.object_type, parentObjectId: new LazyValue( spanComponentsToObjectIdLambda(state, parentObject), @@ -5593,6 +6068,7 @@ function startSpanAndIsLogger( ((parentObject.data.propagated_event ?? undefined) as | StartSpanEventArgs | undefined), + propagatedState, }); return { span, @@ -5695,7 +6171,7 @@ async function* asyncGeneratorWithCurrent( } export function withParent( - parent: string, + parent: string | PropagationContext, callback: () => R, state: BraintrustState | undefined = undefined, ): R { @@ -6598,6 +7074,16 @@ export class Experiment }).toStr(); } + /** + * Return this experiment's Braintrust parent string (`experiment_id:`) for + * the `braintrust.parent` baggage entry, or undefined when it cannot be + * determined synchronously. + */ + public _getOtelParent(): string | undefined { + const id = this.lazyId.getSync().value; + return id ? `experiment_id:${id}` : undefined; + } + /** * Flush any pending rows to the server. */ @@ -6784,6 +7270,13 @@ export class SpanImpl implements Span { private _rootSpanId: string; private _spanParents: string[] | undefined; + // Inbound W3C trace-context state (tracestate + raw traceparent flags) to + // forward on outbound propagation. Captured at the span that received it (via + // extractTraceContext) and inherited by all subspans, so that any inject() + // within the trace re-emits the upstream state unchanged, per the W3C Trace + // Context spec. Not interpreted. + private _propagatedState: PropagatedState | undefined; + public kind = "span" as const; constructor( @@ -6795,9 +7288,11 @@ export class SpanImpl implements Span { parentSpanIds: ParentSpanIds | MultiParentSpanIds | undefined; defaultRootType?: SpanType; spanId?: string; + propagatedState?: PropagatedState | undefined; } & Omit, ) { this._state = args.state; + this._propagatedState = args.propagatedState; const spanAttributes = args.spanAttributes ?? {}; const rawEvent = args.event ?? {}; @@ -7021,6 +7516,7 @@ export class SpanImpl implements Span { parentComputeObjectMetadataArgs: this.parentComputeObjectMetadataArgs, parentSpanIds, propagatedEvent: args?.propagatedEvent ?? this.propagatedEvent, + propagatedState: this._propagatedState, }), }); } @@ -7045,6 +7541,7 @@ export class SpanImpl implements Span { parentComputeObjectMetadataArgs: this.parentComputeObjectMetadataArgs, parentSpanIds, propagatedEvent: args?.propagatedEvent ?? this.propagatedEvent, + propagatedState: this._propagatedState, }), spanId, }); @@ -7081,6 +7578,49 @@ export class SpanImpl implements Span { }).toStr(); } + /** + * Return this span's Braintrust parent string (`project_id:`, + * `project_name:`, or `experiment_id:`) for the `braintrust.parent` + * baggage entry, or undefined when it cannot be determined synchronously. + */ + public _getOtelParent(): string | undefined { + if (this.parentObjectType === SpanObjectTypeV3.PROJECT_LOGS) { + const id = + this.parentComputeObjectMetadataArgs?.project_id || + this.parentObjectId.getSync().value; + const name = this.parentComputeObjectMetadataArgs?.project_name; + if (id) { + return `project_id:${id}`; + } else if (name) { + return `project_name:${name}`; + } + } else if (this.parentObjectType === SpanObjectTypeV3.EXPERIMENT) { + const id = + this.parentComputeObjectMetadataArgs?.experiment_id || + this.parentObjectId.getSync().value; + if (id) { + return `experiment_id:${id}`; + } + } + return undefined; + } + + public inject(carrier?: Record): Record { + const resolvedCarrier = carrier ?? {}; + try { + _injectIntoCarrier(resolvedCarrier, { + traceId: this._rootSpanId, + spanId: this._spanId, + braintrustParent: this._getOtelParent(), + propagatedState: this._propagatedState, + }); + } catch (e) { + // best-effort: never break the caller + debugLogger.warn(`Error injecting trace context: ${e}`); + } + return resolvedCarrier; + } + public async permalink(): Promise { return await permalink(await this.export(), { state: this._state, diff --git a/js/src/propagation.test.ts b/js/src/propagation.test.ts new file mode 100644 index 000000000..136375444 --- /dev/null +++ b/js/src/propagation.test.ts @@ -0,0 +1,894 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ + +/** + * Tests for native W3C trace-context propagation. + * + * Mirrors the Braintrust distributed-tracing spec's test matrix using the pure + * propagation path (no `@opentelemetry/api` dependency). + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + _exportsForTestingOnly, + _injectIntoCarrier, + extractTraceContext, + initLogger, + injectTraceContext, + startSpan, +} from "./logger"; +import { + BAGGAGE_HEADER, + BRAINTRUST_PARENT_KEY, + TRACEPARENT_HEADER, + TRACESTATE_HEADER, + formatTraceparent, + getHeader, + mergeBaggage, + parseBaggage, + parseTraceparent, +} from "./propagation"; +import { SpanComponentsV3 } from "../util/span_identifier_v3"; +import { SpanComponentsV4 } from "../util/span_identifier_v4"; +import { SpanObjectTypeV3 } from "../util/index"; +import { configureNode } from "./node/config"; +import { v4 as uuidv4 } from "uuid"; + +configureNode(); + +const TRACEPARENT_RE = /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/; + +const VALID_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"; +const VALID_SPAN_ID = "00f067aa0ba902b7"; +const VALID_TRACEPARENT = `00-${VALID_TRACE_ID}-${VALID_SPAN_ID}-01`; + +// --------------------------------------------------------------------------- // +// Primitives: traceparent / baggage parse + format +// --------------------------------------------------------------------------- // + +describe("traceparent", () => { + test("parse valid", () => { + expect(parseTraceparent(VALID_TRACEPARENT)).toEqual({ + traceId: VALID_TRACE_ID, + spanId: VALID_SPAN_ID, + traceFlags: "01", + }); + }); + + test("parse strips whitespace", () => { + expect(parseTraceparent(` ${VALID_TRACEPARENT} `)).toEqual({ + traceId: VALID_TRACE_ID, + spanId: VALID_SPAN_ID, + traceFlags: "01", + }); + }); + + test.each([ + "", + null, + undefined, + "invalid", + "00-tooshort-00f067aa0ba902b7-01", + `00-${VALID_TRACE_ID}-00f067aa-01`, // short span id + `99-${VALID_TRACE_ID}-${VALID_SPAN_ID}-01`, // bad version + `00-${"0".repeat(32)}-${VALID_SPAN_ID}-01`, // zero trace id + `00-${VALID_TRACE_ID}-${"0".repeat(16)}-01`, // zero span id + `00-${VALID_TRACE_ID.toUpperCase()}-${VALID_SPAN_ID}-01`, // uppercase hex + ])("parse invalid: %s", (value) => { + expect(parseTraceparent(value as string)).toBeUndefined(); + }); + + test("format round trip", () => { + const tp = formatTraceparent(VALID_TRACE_ID, VALID_SPAN_ID)!; + expect(tp).toMatch(TRACEPARENT_RE); + expect(parseTraceparent(tp)).toEqual({ + traceId: VALID_TRACE_ID, + spanId: VALID_SPAN_ID, + traceFlags: "01", + }); + }); + + test("format rejects non-hex", () => { + expect(formatTraceparent("not-hex", VALID_SPAN_ID)).toBeUndefined(); + expect(formatTraceparent(VALID_TRACE_ID, "00000000-0000")).toBeUndefined(); + expect(formatTraceparent("0".repeat(32), VALID_SPAN_ID)).toBeUndefined(); + }); + + test("parse reports trace flags", () => { + // The raw trace-flags byte must be recoverable so it can be carried through + // extract -> inject. A not-sampled (`-00`) inbound trace must not be + // silently upgraded to sampled. + expect( + parseTraceparent(`00-${VALID_TRACE_ID}-${VALID_SPAN_ID}-01`)?.traceFlags, + ).toBe("01"); + expect( + parseTraceparent(`00-${VALID_TRACE_ID}-${VALID_SPAN_ID}-00`)?.traceFlags, + ).toBe("00"); + }); + + test("format preserves flags round trip", () => { + const parsed = parseTraceparent( + `00-${VALID_TRACE_ID}-${VALID_SPAN_ID}-00`, + )!; + const tp = formatTraceparent( + VALID_TRACE_ID, + VALID_SPAN_ID, + parsed.traceFlags, + )!; + expect(tp.endsWith("-00")).toBe(true); + expect(parseTraceparent(tp)?.traceFlags).toBe("00"); + }); + + test("format defaults to sampled", () => { + expect( + formatTraceparent(VALID_TRACE_ID, VALID_SPAN_ID)?.endsWith("-01"), + ).toBe(true); + }); + + test("format falls back on bad flags", () => { + expect( + formatTraceparent(VALID_TRACE_ID, VALID_SPAN_ID, "zz")?.endsWith("-01"), + ).toBe(true); + }); +}); + +describe("baggage", () => { + test("parse simple", () => { + expect(parseBaggage("braintrust.parent=project_id:abc")).toEqual({ + "braintrust.parent": "project_id:abc", + }); + }); + + test("parse preserves unrelated keys", () => { + const parsed = parseBaggage( + "foo=bar,braintrust.parent=project_id:abc,baz=qux", + ); + expect(parsed["foo"]).toBe("bar"); + expect(parsed["baz"]).toBe("qux"); + expect(parsed["braintrust.parent"]).toBe("project_id:abc"); + }); + + test("parse ignores properties", () => { + expect(parseBaggage("k=v;prop=1")).toEqual({ k: "v" }); + }); + + test.each(["", null, undefined, "no-equals", ",,,"])( + "parse malformed does not throw: %s", + (value) => { + expect(parseBaggage(value as string)).toEqual({}); + }, + ); + + test("parse oversized does not throw", () => { + const big = "x=" + "a".repeat(100000); + // A single member larger than the limit has no complete member to keep, so + // it is dropped entirely (we never decode a partial value). + expect(parseBaggage(big)).toEqual({}); + }); + + test("parse oversized keeps whole members only", () => { + const members = Array.from( + { length: 200 }, + (_, i) => `k${i}=${"v".repeat(100)}`, + ); + const header = members.join(","); + const parsed = parseBaggage(header); + expect(Object.keys(parsed).length).toBeGreaterThan(0); + expect(Object.values(parsed).every((v) => v === "v".repeat(100))).toBe( + true, + ); + const keptKeys = Object.keys(parsed); + expect(keptKeys).toEqual(keptKeys.map((_, i) => `k${i}`)); + }); + + test("parse caps member count", () => { + const header = Array.from({ length: 200 }, (_, i) => `k${i}=v`).join(","); + const parsed = parseBaggage(header); + expect(Object.keys(parsed).length).toBe(64); + expect(Object.keys(parsed)).toEqual( + Array.from({ length: 64 }, (_, i) => `k${i}`), + ); + }); + + test.each([ + [64, 64], + [65, 64], + [10, 10], + ])("parse member count boundary: %i -> %i", (count, expected) => { + const header = Array.from({ length: count }, (_, i) => `k${i}=v`).join(","); + expect(Object.keys(parseBaggage(header)).length).toBe(expected); + }); + + test("parse decodes standard encoder values", () => { + // Standard encoders (e.g. OpenTelemetry's propagator) percent-encode `:` as + // `%3A`. We must fully decode inbound values to interoperate. + expect( + parseBaggage(`${BRAINTRUST_PARENT_KEY}=project_id%3Aabc123`), + ).toEqual({ [BRAINTRUST_PARENT_KEY]: "project_id:abc123" }); + }); +}); + +describe("mergeBaggage", () => { + test("adds braintrust parent when no existing", () => { + const merged = mergeBaggage(null, "project_id:abc"); + expect(parseBaggage(merged)).toEqual({ + [BRAINTRUST_PARENT_KEY]: "project_id:abc", + }); + }); + + test("none parent and no existing returns undefined", () => { + expect(mergeBaggage(null, null)).toBeUndefined(); + expect(mergeBaggage("", null)).toBeUndefined(); + }); + + test("preserves unrelated baggage byte for byte", () => { + const merged = mergeBaggage("path=a%2Fb,user=alice", "project_id:abc")!; + expect(merged).toContain("path=a%2Fb"); + expect(merged).toContain("user=alice"); + // Our own value is percent-encoded for spec compliance (`:` -> `%3A`). + expect(merged).toContain(`${BRAINTRUST_PARENT_KEY}=project_id%3Aabc`); + expect(parseBaggage(merged)[BRAINTRUST_PARENT_KEY]).toBe("project_id:abc"); + }); + + test("does not decode unowned percent sequences", () => { + // `%41` is the percent-encoding of `A`. A transparent relay must not + // collapse `a%41b` to `aAb`. + expect(mergeBaggage("k=a%41b", null)).toBe("k=a%41b"); + }); + + test.each([ + "a%2Fb", // `/` outside our encode set + "x%3Ay", // `:` (what OTel encodes) + "c%2Cd", // encoded comma + "a%3Db", // encoded `=` + "%C3%A9", // multi-byte UTF-8 (é) already percent-encoded + "%2520", // a literal `%20` the upstream double-encoded + ])("unowned value encodings pass through verbatim: %s", (value) => { + expect(mergeBaggage(`vendor=${value}`, null)).toBe(`vendor=${value}`); + }); + + test("multiple unowned members pass through verbatim", () => { + const inbound = "p1=a%2Fb,p2=x%3Ay,p3=c%2Cd"; + const merged = mergeBaggage(inbound, "project_id:p"); + expect(merged).toBe(`${inbound},${BRAINTRUST_PARENT_KEY}=project_id%3Ap`); + }); + + test("preserves member properties", () => { + const merged = mergeBaggage("k=v;meta=1;ttl=30,vendor=y", null); + expect(merged).toBe("k=v;meta=1;ttl=30,vendor=y"); + }); + + test("empty value member is preserved", () => { + expect(mergeBaggage("k=,vendor=y", null)).toBe("k=,vendor=y"); + }); + + test("optional whitespace is trimmed", () => { + const merged = mergeBaggage(" a=1 , b=2 ", "project_id:p"); + expect(merged).toBe(`a=1,b=2,${BRAINTRUST_PARENT_KEY}=project_id%3Ap`); + }); + + test("replaces existing braintrust parent", () => { + const merged = mergeBaggage( + `${BRAINTRUST_PARENT_KEY}=project_id:old,vendor=x`, + "project_id:new", + )!; + const parsed = parseBaggage(merged); + expect(parsed[BRAINTRUST_PARENT_KEY]).toBe("project_id:new"); + expect(parsed["vendor"]).toBe("x"); + expect( + (merged.match(new RegExp(`${BRAINTRUST_PARENT_KEY}=`, "g")) || []).length, + ).toBe(1); + }); + + test("drops existing braintrust parent when no new value", () => { + const merged = mergeBaggage( + `${BRAINTRUST_PARENT_KEY}=project_id:old,vendor=x`, + null, + ); + expect(merged).toBe("vendor=x"); + }); + + test("encodes braintrust parent with reserved chars", () => { + const merged = mergeBaggage(null, "project_name:a,b c"); + expect(parseBaggage(merged)).toEqual({ + [BRAINTRUST_PARENT_KEY]: "project_name:a,b c", + }); + }); + + // The braintrust.parent value embeds a user-controlled project/experiment + // name. Per W3C Baggage §3.3.1.3 a value's unencoded bytes are restricted to + // baggage-octet; everything else MUST be percent-encoded. + test.each([ + 'a"b', // DQUOTE + "a\\b", // backslash + "a\tb", // tab + "a\nb", // newline + "abcd\u00e9", // non-ASCII (é) + "emoji\u{1f600}", // astral plane + "a+b", // literal plus must stay a plus + "a%b", // literal percent MUST be encoded + "a,b c", // comma + space + "a;b=c", // semicolon + equals + ])("braintrust parent value round trips spec-compliant: %s", (name) => { + const value = `project_name:${name}`; + const merged = mergeBaggage(null, value)!; + + // Round-trip through the same decode path the SDK uses on receive. + expect(parseBaggage(merged)).toEqual({ [BRAINTRUST_PARENT_KEY]: value }); + + // ASCII only, and the braintrust.parent member carries no raw + // baggage-octet violators. + // eslint-disable-next-line no-control-regex + expect(/^[\x00-\x7f]*$/.test(merged)).toBe(true); + const member = merged + .split(",") + .find((m) => m.startsWith(`${BRAINTRUST_PARENT_KEY}=`))!; + const encodedVal = member.slice(member.indexOf("=") + 1); + for (const ch of encodedVal) { + const cp = ch.codePointAt(0)!; + const allowed = + cp === 0x21 || + (cp >= 0x23 && cp <= 0x2b) || + (cp >= 0x2d && cp <= 0x3a) || + (cp >= 0x3c && cp <= 0x5b) || + (cp >= 0x5d && cp <= 0x7e); + expect(allowed).toBe(true); + } + }); + + test("skips malformed existing members", () => { + const merged = mergeBaggage("garbage,,k=v,no-equals", "project_id:abc")!; + const parsed = parseBaggage(merged); + expect(parsed["k"]).toBe("v"); + expect(parsed[BRAINTRUST_PARENT_KEY]).toBe("project_id:abc"); + }); + + test("oversized existing relays whole members only", () => { + const members = Array.from( + { length: 200 }, + (_, i) => `k${i}=${"v".repeat(100)}`, + ); + const existing = members.join(","); + const merged = mergeBaggage(existing, "project_id:abc")!; + const parsed = parseBaggage(merged); + expect(new TextEncoder().encode(merged).length).toBeLessThanOrEqual(8192); + expect(parsed[BRAINTRUST_PARENT_KEY]).toBe("project_id:abc"); + const relayed = Object.entries(parsed).filter( + ([k]) => k !== BRAINTRUST_PARENT_KEY, + ); + expect(relayed.length).toBeGreaterThan(0); + expect(relayed.every(([, v]) => v === "v".repeat(100))).toBe(true); + for (const member of merged.split(",")) { + if (member.startsWith(`${BRAINTRUST_PARENT_KEY}=`)) { + continue; + } + expect(member.endsWith("v".repeat(100))).toBe(true); + } + }); + + test("caps member count and reserves slot for braintrust parent", () => { + const existing = Array.from({ length: 200 }, (_, i) => `k${i}=v`).join(","); + const merged = mergeBaggage(existing, "project_id:abc")!; + const parsed = parseBaggage(merged); + expect((merged.match(/,/g) || []).length + 1).toBe(64); + expect(Object.keys(parsed).length).toBe(64); + expect(parsed[BRAINTRUST_PARENT_KEY]).toBe("project_id:abc"); + const relayedKeys = Object.keys(parsed).filter( + (k) => k !== BRAINTRUST_PARENT_KEY, + ); + expect(relayedKeys).toEqual(Array.from({ length: 63 }, (_, i) => `k${i}`)); + }); + + test("member count cap without braintrust parent", () => { + const existing = Array.from({ length: 200 }, (_, i) => `k${i}=v`).join(","); + const merged = mergeBaggage(existing, null)!; + const parsed = parseBaggage(merged); + expect(Object.keys(parsed).length).toBe(64); + expect(Object.keys(parsed)).toEqual( + Array.from({ length: 64 }, (_, i) => `k${i}`), + ); + }); + + test("under member limit keeps all", () => { + const existing = Array.from({ length: 10 }, (_, i) => `k${i}=v`).join(","); + const merged = mergeBaggage(existing, "project_id:abc")!; + const parsed = parseBaggage(merged); + expect(Object.keys(parsed).length).toBe(11); + expect(parsed[BRAINTRUST_PARENT_KEY]).toBe("project_id:abc"); + }); +}); + +test("getHeader case insensitive", () => { + const headers = { TraceParent: VALID_TRACEPARENT, BAGGAGE: "foo=bar" }; + expect(getHeader(headers, "traceparent")).toBe(VALID_TRACEPARENT); + expect(getHeader(headers, "baggage")).toBe("foo=bar"); + expect(getHeader(headers, "missing")).toBeUndefined(); +}); + +// --------------------------------------------------------------------------- // +// Send / receive / round-trip against a live logger +// --------------------------------------------------------------------------- // + +const PROJECT_NAME = "propagation-test"; + +describe("inject / extract / round-trip", () => { + let memoryLogger: ReturnType< + typeof _exportsForTestingOnly.useTestBackgroundLogger + >; + + beforeEach(() => { + _exportsForTestingOnly.simulateLoginForTests(); + _exportsForTestingOnly.resetIdGenStateForTests(); + memoryLogger = _exportsForTestingOnly.useTestBackgroundLogger(); + }); + + afterEach(() => { + _exportsForTestingOnly.clearTestBackgroundLogger(); + _exportsForTestingOnly.simulateLogoutForTests(); + }); + + function makeLogger() { + return initLogger({ projectName: PROJECT_NAME }); + } + + describe("inject", () => { + test("traceparent well-formed and matches span", () => { + const logger = makeLogger(); + const span = logger.startSpan({ name: "svc_a" }); + const carrier = span.inject({}); + span.end(); + + const tp = carrier[TRACEPARENT_HEADER]; + expect(tp).toMatch(TRACEPARENT_RE); + const parsed = parseTraceparent(tp)!; + expect(parsed.traceId).toBe(span.rootSpanId); + expect(parsed.spanId).toBe(span.spanId); + }); + + test("baggage contains braintrust parent", () => { + const logger = makeLogger(); + const span = logger.startSpan({ name: "svc_a" }); + const carrier = span.inject({}); + span.end(); + + const parsed = parseBaggage(carrier[BAGGAGE_HEADER]); + expect(parsed[BRAINTRUST_PARENT_KEY]).toBe( + `project_name:${PROJECT_NAME}`, + ); + }); + + test("preexisting baggage preserved", () => { + const logger = makeLogger(); + const span = logger.startSpan({ name: "svc_a" }); + const carrier = span.inject({ [BAGGAGE_HEADER]: "user=alice,team=eng" }); + span.end(); + + const parsed = parseBaggage(carrier[BAGGAGE_HEADER]); + expect(parsed["user"]).toBe("alice"); + expect(parsed["team"]).toBe("eng"); + expect(parsed[BRAINTRUST_PARENT_KEY]).toBe( + `project_name:${PROJECT_NAME}`, + ); + }); + + test("title-cased baggage emits single lowercase header", () => { + const logger = makeLogger(); + const span = logger.startSpan({ name: "svc_a" }); + const carrier = span.inject({ Baggage: "user=alice" }); + span.end(); + + const baggageKeys = Object.keys(carrier).filter( + (k) => k.toLowerCase() === BAGGAGE_HEADER, + ); + expect(baggageKeys).toEqual([BAGGAGE_HEADER]); + const parsed = parseBaggage(carrier[BAGGAGE_HEADER]); + expect(parsed["user"]).toBe("alice"); + expect(parsed[BRAINTRUST_PARENT_KEY]).toBe( + `project_name:${PROJECT_NAME}`, + ); + }); + + test("title-cased traceparent emits single lowercase header", () => { + const logger = makeLogger(); + const span = logger.startSpan({ name: "svc_a" }); + const carrier = span.inject({ Traceparent: "stale" }); + span.end(); + + const traceparentKeys = Object.keys(carrier).filter( + (k) => k.toLowerCase() === TRACEPARENT_HEADER, + ); + expect(traceparentKeys).toEqual([TRACEPARENT_HEADER]); + const parsed = parseTraceparent(carrier[TRACEPARENT_HEADER])!; + expect(parsed.traceId).toBe(span.rootSpanId); + expect(parsed.spanId).toBe(span.spanId); + }); + + test("never emits x-bt-parent", () => { + const logger = makeLogger(); + const span = logger.startSpan({ name: "svc_a" }); + const carrier = span.inject({}); + span.end(); + expect(Object.keys(carrier).map((k) => k.toLowerCase())).not.toContain( + "x-bt-parent", + ); + }); + + test("no braintrust parent injects traceparent without baggage", () => { + const carrier: Record = {}; + _injectIntoCarrier(carrier, { + traceId: VALID_TRACE_ID, + spanId: VALID_SPAN_ID, + braintrustParent: undefined, + }); + expect(carrier[TRACEPARENT_HEADER]).toMatch(TRACEPARENT_RE); + expect(BAGGAGE_HEADER in carrier).toBe(false); + }); + + test("no braintrust parent preserves existing baggage without bt key", () => { + const carrier: Record = { + [BAGGAGE_HEADER]: "user=alice", + }; + _injectIntoCarrier(carrier, { + traceId: VALID_TRACE_ID, + spanId: VALID_SPAN_ID, + braintrustParent: undefined, + }); + const parsed = parseBaggage(carrier[BAGGAGE_HEADER]); + expect(parsed["user"]).toBe("alice"); + expect(BRAINTRUST_PARENT_KEY in parsed).toBe(false); + }); + + test("injectTraceContext free function", () => { + const logger = makeLogger(); + let captured: { root: string; span: string } | undefined; + let carrier: Record = {}; + logger.traced( + (span) => { + carrier = injectTraceContext(); + captured = { root: span.rootSpanId, span: span.spanId }; + }, + { name: "svc_a" }, + ); + const parsed = parseTraceparent(carrier[TRACEPARENT_HEADER])!; + expect(parsed.traceId).toBe(captured!.root); + expect(parsed.spanId).toBe(captured!.span); + }); + + test("inject no current span is safe", () => { + const carrier = injectTraceContext({}); + expect(TRACEPARENT_HEADER in carrier).toBe(false); + }); + }); + + describe("extract", () => { + test("traceparent with baggage parent", () => { + const logger = makeLogger(); + const ctx = extractTraceContext({ + traceparent: VALID_TRACEPARENT, + baggage: `${BRAINTRUST_PARENT_KEY}=project_id:abc123`, + }); + const span = logger.startSpan({ name: "h", parent: ctx }); + expect(span.rootSpanId).toBe(VALID_TRACE_ID); + expect(span.spanParents).toEqual([VALID_SPAN_ID]); + span.end(); + }); + + test("traceparent baggage with unrelated keys", () => { + const logger = makeLogger(); + const ctx = extractTraceContext({ + traceparent: VALID_TRACEPARENT, + baggage: `user=alice,${BRAINTRUST_PARENT_KEY}=project_id:abc,team=eng`, + }); + const span = logger.startSpan({ name: "h", parent: ctx }); + expect(span.rootSpanId).toBe(VALID_TRACE_ID); + expect(span.spanParents).toEqual([VALID_SPAN_ID]); + span.end(); + }); + + test("traceparent no baggage uses current logger", () => { + const logger = makeLogger(); + const ctx = extractTraceContext({ traceparent: VALID_TRACEPARENT }); + expect(ctx).not.toBeUndefined(); + const span = logger.startSpan({ name: "h", parent: ctx }); + expect(span.rootSpanId).toBe(VALID_TRACE_ID); + expect(span.spanParents).toEqual([VALID_SPAN_ID]); + span.end(); + }); + + test("no headers returns undefined", () => { + expect(extractTraceContext({})).toBeUndefined(); + expect(extractTraceContext(null)).toBeUndefined(); + expect(extractTraceContext(undefined)).toBeUndefined(); + }); + + test("malformed traceparent returns undefined", () => { + expect( + extractTraceContext({ + traceparent: "garbage", + baggage: `${BRAINTRUST_PARENT_KEY}=project_id:abc`, + }), + ).toBeUndefined(); + }); + + test("case insensitive headers", () => { + const logger = makeLogger(); + const ctx = extractTraceContext({ + TraceParent: VALID_TRACEPARENT, + Baggage: `${BRAINTRUST_PARENT_KEY}=project_id:abc`, + }); + const span = logger.startSpan({ name: "h", parent: ctx }); + expect(span.rootSpanId).toBe(VALID_TRACE_ID); + expect(span.spanParents).toEqual([VALID_SPAN_ID]); + span.end(); + }); + + test("extract returns opaque dict", () => { + const ctx = extractTraceContext({ + traceparent: VALID_TRACEPARENT, + baggage: `${BRAINTRUST_PARENT_KEY}=project_id:abc`, + tracestate: "congo=t61", + })!; + expect(typeof ctx).toBe("object"); + expect(ctx[TRACEPARENT_HEADER]).toBe(VALID_TRACEPARENT); + }); + + test("no parent and logger present starts span without throwing", () => { + const logger = makeLogger(); + const ctx = extractTraceContext({ traceparent: VALID_TRACEPARENT }); + const span = logger.startSpan({ name: "h", parent: ctx }); + expect(span.spanId).not.toBeUndefined(); + span.end(); + }); + }); + + test("round trip inject extract", () => { + const logger = makeLogger(); + const spanA = logger.startSpan({ name: "svc_a" }); + const carrier = spanA.inject({}); + const aRoot = spanA.rootSpanId; + const aSpan = spanA.spanId; + spanA.end(); + + const parent = extractTraceContext(carrier); + const spanB = logger.startSpan({ name: "svc_b", parent }); + expect(spanB.rootSpanId).toBe(aRoot); + expect(spanB.spanParents).toEqual([aSpan]); + spanB.end(); + }); + + test("inject does not break span emission without parent", async () => { + // inject is best-effort and must never drop the span. Use a projectId so + // draining doesn't require a metadata round-trip. + const logger = initLogger({ + projectName: "emit-test", + projectId: "emit-test-project-id", + }); + const span = logger.startSpan({ name: "svc_a" }); + span.inject({}); + span.log({ output: "hello" }); + span.end(); + await memoryLogger.flush(); + const events = (await memoryLogger.drain()) as any[]; + expect(events.some((e) => e["output"] === "hello")).toBe(true); + }); + + test("legacy export slug round trips with hex ids", () => { + const logger = makeLogger(); + const parent = logger.startSpan({ name: "parent" }); + const pRoot = parent.rootSpanId; + const pSpan = parent.spanId; + parent.end(); + + // Ids are OTEL-shaped hex by default. + expect(pSpan.length).toBe(16); + expect(pRoot.length).toBe(32); + + // Build a slug synchronously from the hex ids (mirrors span.export()). + const slug = new SpanComponentsV4({ + object_type: SpanObjectTypeV3.PROJECT_LOGS, + compute_object_metadata_args: { project_name: PROJECT_NAME }, + row_id: "bt-row", + span_id: pSpan, + root_span_id: pRoot, + }).toStr(); + + const child = logger.startSpan({ name: "child", parent: slug }); + expect(child.rootSpanId).toBe(pRoot); + expect(child.spanParents).toEqual([pSpan]); + child.end(); + }); + + test("legacy parent slug (UUID) linked in hex mode", () => { + const logger = makeLogger(); + const pSpan = uuidv4(); + const pRoot = uuidv4(); + const legacySlug = new SpanComponentsV3({ + object_type: SpanObjectTypeV3.PROJECT_LOGS, + object_id: "legacy-proj", + row_id: uuidv4(), + span_id: pSpan, + root_span_id: pRoot, + }).toStr(); + + const child = logger.startSpan({ name: "child", parent: legacySlug }); + // Links to the slug's UUID ids; the child's own span id stays hex. + expect(child.rootSpanId).toBe(pRoot); + expect(child.spanParents).toEqual([pSpan]); + expect(child.spanId.length).toBe(16); + child.end(); + }); + + test("legacy parent slug linked via top-level startSpan", () => { + makeLogger(); + const pSpan = uuidv4(); + const pRoot = uuidv4(); + const legacySlug = new SpanComponentsV3({ + object_type: SpanObjectTypeV3.PROJECT_LOGS, + object_id: "legacy-proj", + row_id: uuidv4(), + span_id: pSpan, + root_span_id: pRoot, + }).toStr(); + + const child = startSpan({ name: "child", parent: legacySlug }); + expect(child.rootSpanId).toBe(pRoot); + expect(child.spanParents).toEqual([pSpan]); + expect(child.spanId.length).toBe(16); + child.end(); + }); +}); + +// --------------------------------------------------------------------------- // +// tracestate / trace-flags pass-through +// --------------------------------------------------------------------------- // + +const UPSTREAM_TRACESTATE = "congo=t61rcWkgMzE,rojo=00f067aa0ba902b7"; + +describe("tracestate / flags pass-through", () => { + beforeEach(() => { + _exportsForTestingOnly.simulateLoginForTests(); + _exportsForTestingOnly.resetIdGenStateForTests(); + _exportsForTestingOnly.useTestBackgroundLogger(); + }); + + afterEach(() => { + _exportsForTestingOnly.clearTestBackgroundLogger(); + _exportsForTestingOnly.simulateLogoutForTests(); + }); + + function makeLogger() { + return initLogger({ projectName: PROJECT_NAME }); + } + + test("extract then inject forwards tracestate", () => { + const logger = makeLogger(); + const parent = extractTraceContext({ + traceparent: VALID_TRACEPARENT, + baggage: `${BRAINTRUST_PARENT_KEY}=project_id:abc`, + tracestate: UPSTREAM_TRACESTATE, + }); + const span = logger.startSpan({ name: "mid", parent }); + const outbound = span.inject({}); + span.end(); + expect(outbound[TRACESTATE_HEADER]).toBe(UPSTREAM_TRACESTATE); + expect(outbound[TRACEPARENT_HEADER]).toMatch(TRACEPARENT_RE); + }); + + test("no tracestate emitted when none inbound", () => { + const logger = makeLogger(); + const span = logger.startSpan({ name: "root" }); + const outbound = span.inject({}); + span.end(); + expect(TRACESTATE_HEADER in outbound).toBe(false); + }); + + test("extract then inject preserves unsampled flag", () => { + const logger = makeLogger(); + const unsampled = `00-${VALID_TRACE_ID}-${VALID_SPAN_ID}-00`; + const parent = extractTraceContext({ + traceparent: unsampled, + baggage: `${BRAINTRUST_PARENT_KEY}=project_id:abc`, + }); + const span = logger.startSpan({ name: "mid", parent }); + const outbound = span.inject({}); + span.end(); + expect(outbound[TRACEPARENT_HEADER].endsWith("-00")).toBe(true); + }); + + test("extract then inject preserves sampled flag", () => { + const logger = makeLogger(); + const parent = extractTraceContext({ + traceparent: VALID_TRACEPARENT, // ...-01 + baggage: `${BRAINTRUST_PARENT_KEY}=project_id:abc`, + }); + const span = logger.startSpan({ name: "mid", parent }); + const outbound = span.inject({}); + span.end(); + expect(outbound[TRACEPARENT_HEADER].endsWith("-01")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- // +// Legacy UUID mode +// --------------------------------------------------------------------------- // + +describe("legacy UUID mode", () => { + let prevLegacy: string | undefined; + let prevOtel: string | undefined; + + beforeEach(() => { + prevLegacy = process.env.BRAINTRUST_LEGACY_IDS; + prevOtel = process.env.BRAINTRUST_OTEL_COMPAT; + delete process.env.BRAINTRUST_OTEL_COMPAT; + process.env.BRAINTRUST_LEGACY_IDS = "true"; + _exportsForTestingOnly.simulateLoginForTests(); + _exportsForTestingOnly.resetIdGenStateForTests(); + _exportsForTestingOnly.useTestBackgroundLogger(); + }); + + afterEach(() => { + _exportsForTestingOnly.clearTestBackgroundLogger(); + _exportsForTestingOnly.simulateLogoutForTests(); + if (prevLegacy === undefined) { + delete process.env.BRAINTRUST_LEGACY_IDS; + } else { + process.env.BRAINTRUST_LEGACY_IDS = prevLegacy; + } + if (prevOtel === undefined) { + delete process.env.BRAINTRUST_OTEL_COMPAT; + } else { + process.env.BRAINTRUST_OTEL_COMPAT = prevOtel; + } + _exportsForTestingOnly.resetIdGenStateForTests(); + }); + + test("inject no-ops in legacy UUID mode", () => { + const logger = initLogger({ projectName: "legacy-inject" }); + const span = logger.startSpan({ name: "p" }); + // Legacy spans use UUID ids (share root == span). + expect(span.spanId.length).toBe(36); + const carrier = span.inject({ existing: "header" }); + span.end(); + + expect(carrier).toEqual({ existing: "header" }); + expect(TRACEPARENT_HEADER in carrier).toBe(false); + expect(BAGGAGE_HEADER in carrier).toBe(false); + }); + + test("legacy parent slug (UUID) linked in legacy mode", () => { + const logger = initLogger({ projectName: "legacy-proj" }); + const pSpan = uuidv4(); + const pRoot = uuidv4(); + const legacySlug = new SpanComponentsV3({ + object_type: SpanObjectTypeV3.PROJECT_LOGS, + object_id: "legacy-proj", + row_id: uuidv4(), + span_id: pSpan, + root_span_id: pRoot, + }).toStr(); + + const child = logger.startSpan({ name: "child", parent: legacySlug }); + expect(child.rootSpanId).toBe(pRoot); + expect(child.spanParents).toEqual([pSpan]); + child.end(); + }); + + test("hex parent slug linked in legacy mode", () => { + const logger = initLogger({ projectName: "legacy-proj" }); + const pSpan = "00f067aa0ba902b7"; // 8-byte hex + const pRoot = "4bf92f3577b34da6a3ce929d0e0e4736"; // 16-byte hex + const hexSlug = new SpanComponentsV4({ + object_type: SpanObjectTypeV3.PROJECT_LOGS, + object_id: "legacy-proj", + row_id: "bt-row", + span_id: pSpan, + root_span_id: pRoot, + }).toStr(); + + const child = logger.startSpan({ name: "child", parent: hexSlug }); + // Links to the slug's hex ids; the child's own span id stays UUID. + expect(child.rootSpanId).toBe(pRoot); + expect(child.spanParents).toEqual([pSpan]); + expect(child.spanId.length).toBe(36); + child.end(); + }); +}); diff --git a/js/src/propagation.ts b/js/src/propagation.ts new file mode 100644 index 000000000..5ed70615e --- /dev/null +++ b/js/src/propagation.ts @@ -0,0 +1,377 @@ +/** + * Native W3C Trace Context propagation for Braintrust. + * + * This module implements the propagation wire format described in the Braintrust + * distributed-tracing spec, in pure TypeScript with no dependency on + * `@opentelemetry/api`. It parses and serializes the W3C `traceparent` and + * `baggage` headers and the Braintrust `braintrust.parent` baggage entry. + * + * Trace identity (trace id + parent span id) is carried in `traceparent`; the + * Braintrust container the trace belongs to (project/experiment) is carried in + * `baggage` under the `braintrust.parent` key. + */ + +export const TRACEPARENT_HEADER = "traceparent"; +export const TRACESTATE_HEADER = "tracestate"; +export const BAGGAGE_HEADER = "baggage"; +export const BRAINTRUST_PARENT_KEY = "braintrust.parent"; + +// Trace-flags byte we emit for traces we originate: sampled (low bit set). +export const DEFAULT_TRACE_FLAGS = "01"; + +/** + * Parsed W3C `traceparent` fields. + * + * `traceFlags` is the raw 2-hex trace-flags byte (e.g. `"01"` sampled, `"00"` + * not sampled), kept raw so any future flag bits survive a parse -> format + * round trip without per-bit handling. + */ +export interface ParsedTraceparent { + traceId: string; + spanId: string; + traceFlags: string; +} + +/** + * Inbound W3C trace-context state that Braintrust forwards but never interprets. + * + * Captured at the span created from inbound headers (via `extractTraceContext`) + * and inherited by every subspan, so that any `inject()` within the trace + * re-emits the upstream state unchanged, per the W3C Trace Context spec. + * + * - `tracestate`: the W3C `tracestate` header (opaque vendor state). + * - `traceFlags`: the raw 2-hex `traceparent` trace-flags byte. Stored raw so + * future flag bits are preserved without per-bit handling. + */ +export interface PropagatedState { + tracestate?: string; + traceFlags?: string; +} + +// W3C traceparent: version-traceid-parentid-flags, version 00, lowercase hex. +const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/; +const ZERO_TRACE_ID = "0".repeat(32); +const ZERO_SPAN_ID = "0".repeat(16); + +// W3C Baggage limits (§3.3.2): a conformant baggage-string must satisfy *both* +// of these conditions. https://www.w3.org/TR/baggage/#limits +// - Condition 1: at most 64 list-members. +// - Condition 2: at most 8192 bytes total. +// +// We reuse these as a defensive bound on parsing/relaying untrusted inbound +// headers: the header arrives from the network and is attacker-controllable, so +// we never split or decode an unbounded string. When an inbound header exceeds +// either limit we drop trailing list-members rather than truncate one mid-value: +// the spec says a platform that cannot propagate all list-members "MUST NOT +// propagate any partial list-members", so we keep only the leading whole members +// that fit within both limits. +const MAX_BAGGAGE_LENGTH = 8192; +const MAX_BAGGAGE_MEMBERS = 64; + +const _utf8Encoder = new TextEncoder(); + +function utf8ByteLength(value: string): number { + return _utf8Encoder.encode(value).length; +} + +/** + * Return `value` bounded to the W3C limits, never splitting a list-member + * mid-value. + * + * Enforces both §3.3.2 limits: at most `MAX_BAGGAGE_MEMBERS` list-members and at + * most `MAX_BAGGAGE_LENGTH` UTF-8 bytes (the spec limit is a byte count, not + * code points). If the header is within both limits it is returned unchanged. + * Otherwise we keep the leading whole members that fit and drop the rest -- a + * trailing member that would be partial is never kept. If even the first member + * exceeds the byte limit there is no complete member to keep, so we return an + * empty string. + */ +function capBaggageToMemberBoundary(value: string): string { + const totalBytes = utf8ByteLength(value); + const withinBytes = totalBytes <= MAX_BAGGAGE_LENGTH; + // Cheap structural cap on member count: actual members are <= comma count + 1. + let commaCount = 0; + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) === 0x2c) { + commaCount++; + } + } + const withinMembers = commaCount < MAX_BAGGAGE_MEMBERS; + if (withinBytes && withinMembers) { + return value; + } + + // Walk members in order, keeping whole ones until either limit is reached. We + // account on UTF-8 byte length so the byte budget is exact, and we only ever + // cut on comma boundaries so partial code points are never split. + const kept: string[] = []; + let length = 0; + for (const rawMember of value.split(",")) { + if (kept.length >= MAX_BAGGAGE_MEMBERS) { + break; + } + const cost = utf8ByteLength(rawMember) + (kept.length ? 1 : 0); + if (length + cost > MAX_BAGGAGE_LENGTH) { + break; + } + kept.push(rawMember); + length += cost; + } + if (!kept.length) { + // The first member alone already exceeds the byte limit. + return ""; + } + return kept.join(","); +} + +/** + * Case-insensitive header lookup. + * + * Some frameworks normalize header names to title case (e.g. `Traceparent`) + * while the W3C keys are lowercase. Returns the first matching value or + * undefined. + */ +export function getHeader( + headers: Record | null | undefined, + name: string, +): string | undefined { + if (!headers) { + return undefined; + } + // Fast path: exact (lowercase) match. + const value = headers[name]; + if (value !== undefined && value !== null) { + return typeof value === "string" ? value : String(value); + } + const lowered = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lowered) { + const val = headers[key]; + if (val === undefined || val === null) { + return undefined; + } + return typeof val === "string" ? val : String(val); + } + } + return undefined; +} + +function isHex(value: string | undefined | null, length: number): boolean { + if (typeof value !== "string" || value.length !== length) { + return false; + } + for (let i = 0; i < value.length; i++) { + const c = value[i]; + const isDigit = c >= "0" && c <= "9"; + const isLowerHex = c >= "a" && c <= "f"; + if (!isDigit && !isLowerHex) { + return false; + } + } + return true; +} + +/** + * Parse a W3C `traceparent` value into a {@link ParsedTraceparent}. + * + * Returns `{ traceId, spanId, traceFlags }`, where `traceFlags` is the raw + * 2-hex trace-flags byte. Returns undefined for any malformed value (bad + * version, wrong length, non-hex, or all-zero ids). Never throws. + */ +export function parseTraceparent( + value: string | undefined | null, +): ParsedTraceparent | undefined { + if (!value || typeof value !== "string") { + return undefined; + } + const match = TRACEPARENT_RE.exec(value.trim()); + if (!match) { + return undefined; + } + const traceId = match[1]; + const spanId = match[2]; + const traceFlags = match[3]; + if (traceId === ZERO_TRACE_ID || spanId === ZERO_SPAN_ID) { + return undefined; + } + return { traceId, spanId, traceFlags }; +} + +/** + * Serialize a W3C `traceparent` value from hex trace/span ids. + * + * `traceFlags` is the raw 2-hex trace-flags byte to emit; it is forwarded + * verbatim so any upstream/future flag bits survive. Falls back to + * `DEFAULT_TRACE_FLAGS` (sampled) when not a valid 2-hex byte. Returns undefined + * if the ids are not valid W3C-shaped hex (so callers can omit the header rather + * than emit something malformed). + */ +export function formatTraceparent( + traceId: string | undefined | null, + spanId: string | undefined | null, + traceFlags: string = DEFAULT_TRACE_FLAGS, +): string | undefined { + if (!isHex(traceId, 32) || traceId === ZERO_TRACE_ID) { + return undefined; + } + if (!isHex(spanId, 16) || spanId === ZERO_SPAN_ID) { + return undefined; + } + const flags = isHex(traceFlags, 2) ? traceFlags : DEFAULT_TRACE_FLAGS; + return `00-${traceId}-${spanId}-${flags}`; +} + +// Per W3C Baggage (§3.3.1.3), a value's unencoded bytes are restricted to the +// `baggage-octet` set: +// +// baggage-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E +// +// i.e. US-ASCII excluding CTLs, whitespace, DQUOTE, comma, semicolon, and +// backslash; the percent sign MUST be encoded; and any non-ASCII code point MUST +// be percent-encoded (as UTF-8 octets). We only ever encode our own +// `braintrust.parent` member, whose value embeds an arbitrary, user-controlled +// project/experiment name -- so it can contain any of those characters. +// +// `encodeURIComponent` percent-encodes every byte outside the set +// `A-Z a-z 0-9 - _ . ! ~ * ' ( )`. Every char it leaves unencoded is within +// `baggage-octet`, so the result is always spec-compliant: we may over-encode +// some characters that are technically legal unencoded (the spec explicitly +// permits this), but we never emit a byte that violates the grammar. Space is +// emitted as `%20`, not the form-urlencoded `+`. +// +// On receive we decode with `decodeURIComponent`, the inverse of +// `encodeURIComponent` (`%20` -> space, multi-byte UTF-8 reassembled). A literal +// `+` in a value is encoded to `%2B` and decoded back to `+`, so it survives. +// +// Byte-for-byte pass-through of *other* vendors' baggage is handled separately +// by `mergeBaggage`, which forwards their raw member strings unchanged rather +// than round-tripping them through this codec. + +function percentEncode(value: string): string { + return encodeURIComponent(value); +} + +function percentDecode(value: string): string { + if (!value.includes("%")) { + return value; + } + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** + * Parse a W3C `baggage` header into an ordered map of key -> value. + * + * Tolerates malformed/oversized input by skipping bad entries; never throws. + * Property metadata (after ';') is ignored. Keys and values are percent-decoded. + */ +export function parseBaggage( + value: string | undefined | null, +): Record { + const result: Record = {}; + if (!value || typeof value !== "string") { + return result; + } + // Oversized header: bound the work to whole list-members (never mid-value). + const bounded = capBaggageToMemberBoundary(value); + for (let member of bounded.split(",")) { + member = member.trim(); + if (!member || !member.includes("=")) { + continue; + } + // Strip any ';'-delimited properties. + member = member.split(";", 1)[0]; + const eq = member.indexOf("="); + const key = percentDecode(member.slice(0, eq).trim()); + const val = member.slice(eq + 1).trim(); + if (!key) { + continue; + } + result[key] = percentDecode(val); + } + return result; +} + +/** + * Merge a `braintrust.parent` value into an existing `baggage` header. + * + * This preserves every other vendor's baggage member byte-for-byte: their raw + * `key=value` substrings (properties included) are forwarded exactly as received + * rather than decoded and re-encoded. Decoding then re-encoding would silently + * rewrite another vendor's percent-encoding (e.g. `path=a%2Fb` -> `path=a/b`), + * so we keep Braintrust a transparent relay. Whitespace around list members is + * insignificant per W3C and is trimmed. + * + * Only the `braintrust.parent` member is (re)serialized, by us, from the + * `braintrustParent` argument. Any pre-existing `braintrust.parent` member in + * `existing` is dropped in favor of the supplied value. + * + * The result is bounded to both W3C limits (§3.3.2): at most 64 list-members and + * at most 8192 bytes. Our own `braintrust.parent` member is prioritized: its + * byte cost and one member slot are reserved first, then relayed members are + * appended in order until either budget is exhausted, always on whole-member + * boundaries (never a partial list-member). Relayed members that do not fit are + * dropped. + * + * Returns the merged header value, or undefined if there is nothing to emit (so + * callers omit the header rather than emit an empty one). + */ +export function mergeBaggage( + existing: string | undefined | null, + braintrustParent: string | undefined | null, +): string | undefined { + let btMember: string | undefined = undefined; + if (braintrustParent) { + const encodedKey = percentEncode(BRAINTRUST_PARENT_KEY); + const encodedVal = percentEncode(String(braintrustParent)); + btMember = `${encodedKey}=${encodedVal}`; + } + + // Reserve both budgets for our own member first so it always survives; relayed + // members fill whatever space remains. + let byteBudget = MAX_BAGGAGE_LENGTH; + let memberBudget = MAX_BAGGAGE_MEMBERS; + if (btMember !== undefined) { + // +1 for the comma joining our member to any preceding relayed member. + byteBudget -= utf8ByteLength(btMember) + 1; + memberBudget -= 1; + } + + const relayed: string[] = []; + let length = 0; + if (existing && typeof existing === "string") { + for (const rawMember of existing.split(",")) { + const member = rawMember.trim(); + if (!member || !member.includes("=")) { + continue; + } + // Identify the key (ignoring ';'-delimited properties) only to skip any + // inbound braintrust.parent; everything else is forwarded raw. + const keyPart = member.split(";", 1)[0].split("=", 1)[0]; + const key = percentDecode(keyPart.trim()); + if (key === BRAINTRUST_PARENT_KEY) { + continue; + } + // Stop at whole-member boundaries once either budget is exhausted; we + // never forward a partial member (W3C §3.3.2). + if (relayed.length >= memberBudget) { + break; + } + const cost = utf8ByteLength(member) + (relayed.length ? 1 : 0); + if (length + cost > byteBudget) { + break; + } + relayed.push(member); + length += cost; + } + } + + const members = btMember !== undefined ? [...relayed, btMember] : relayed; + if (!members.length) { + return undefined; + } + return members.join(","); +} diff --git a/js/util/http_headers.ts b/js/util/http_headers.ts index c7984f3a0..344df28e8 100644 --- a/js/util/http_headers.ts +++ b/js/util/http_headers.ts @@ -1,6 +1,7 @@ // A response header whose presence indicates that an object insert operation import { SpanComponentsV3, SpanObjectTypeV3 } from "./span_identifier_v3"; +import { SpanComponentsV4 } from "./span_identifier_v4"; // (POST or PUT) encountered an existing version of the object. export const BT_FOUND_EXISTING_HEADER = "x-bt-found-existing"; @@ -19,7 +20,9 @@ const PROJECT_ID_PREFIX = "project_id:"; const PROJECT_NAME_PREFIX = "project_name:"; const PLAYGROUND_ID_PREFIX = "playground_id:"; -export function resolveParentHeader(header: string): SpanComponentsV3 { +export function resolveParentHeader( + header: string, +): SpanComponentsV3 | SpanComponentsV4 { if (header.startsWith(EXPERIMENT_ID_PREFIX)) { return new SpanComponentsV3({ object_type: SpanObjectTypeV3.EXPERIMENT, @@ -45,5 +48,7 @@ export function resolveParentHeader(header: string): SpanComponentsV3 { }); } - return SpanComponentsV3.fromStr(header); + // SpanComponentsV4.fromStr decodes both V4 (the SDK default) and older V3 + // slugs, so a serialized parent of either version resolves correctly. + return SpanComponentsV4.fromStr(header); }