diff --git a/braintrust-java-agent/bootstrap/src/main/java/dev/braintrust/system/AgentBootstrap.java b/braintrust-java-agent/bootstrap/src/main/java/dev/braintrust/system/AgentBootstrap.java index bdca80a0..6498d187 100644 --- a/braintrust-java-agent/bootstrap/src/main/java/dev/braintrust/system/AgentBootstrap.java +++ b/braintrust-java-agent/bootstrap/src/main/java/dev/braintrust/system/AgentBootstrap.java @@ -212,6 +212,9 @@ private static boolean isRunningAfterDatadogAgent(List agents) { } private static void enableOtelSDKAutoconfiguration() { + // Silence agent-internal SLF4J output by default + setPropertyIfAbsent("org.slf4j.simpleLogger.log.dev.braintrust", "warn"); + // Enable OTel SDK autoconfiguration. When anyone first calls // GlobalOpenTelemetry.get(), the SDK will be built using autoconfigure, which // discovers our BraintrustAutoConfigCustomizer via ServiceLoader and injects the diff --git a/braintrust-java-agent/internal/build.gradle b/braintrust-java-agent/internal/build.gradle index f32f82ff..38d07a66 100644 --- a/braintrust-java-agent/internal/build.gradle +++ b/braintrust-java-agent/internal/build.gradle @@ -20,6 +20,9 @@ dependencies { // SLF4J API — needed at compile time for Lombok's @Slf4j annotation implementation "org.slf4j:slf4j-api:${slf4jVersion}" + // SLF4J Simple binding — bundled so the agent always has a logging backend + runtimeOnly "org.slf4j:slf4j-simple:${slf4jVersion}" + // ByteBuddy for bytecode manipulation — bundled as .classdata in BraintrustClassLoader implementation 'net.bytebuddy:byte-buddy:1.17.5' diff --git a/braintrust-java-agent/internal/src/main/java/dev/braintrust/agent/OtelAutoConfig.java b/braintrust-java-agent/internal/src/main/java/dev/braintrust/agent/OtelAutoConfig.java new file mode 100644 index 00000000..cadcb569 --- /dev/null +++ b/braintrust-java-agent/internal/src/main/java/dev/braintrust/agent/OtelAutoConfig.java @@ -0,0 +1,33 @@ +package dev.braintrust.agent; + +import com.google.auto.service.AutoService; +import dev.braintrust.Braintrust; +import dev.braintrust.bootstrap.BraintrustBridge; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@AutoService(AutoConfigurationCustomizerProvider.class) +public class OtelAutoConfig implements AutoConfigurationCustomizerProvider { + @Override + public void customize(AutoConfigurationCustomizer autoConfiguration) { + autoConfiguration.addTracerProviderCustomizer( + ((sdkTracerProviderBuilder, configProperties) -> { + if (!BraintrustBridge.otelInstallCount.compareAndSet(0, 1)) { + log.warn( + "otel install invoked more than once. This should not happen." + + " Bailing."); + return sdkTracerProviderBuilder; + } + Braintrust.get() + .openTelemetryEnable( + sdkTracerProviderBuilder, + SdkLoggerProvider.builder(), + SdkMeterProvider.builder()); + return sdkTracerProviderBuilder; + })); + } +} diff --git a/braintrust-java-agent/internal/src/main/java/dev/braintrust/agent/dd/DDSpanConverter.java b/braintrust-java-agent/internal/src/main/java/dev/braintrust/agent/dd/DDSpanConverter.java index ac2348c2..78d51f55 100644 --- a/braintrust-java-agent/internal/src/main/java/dev/braintrust/agent/dd/DDSpanConverter.java +++ b/braintrust-java-agent/internal/src/main/java/dev/braintrust/agent/dd/DDSpanConverter.java @@ -118,15 +118,24 @@ static void replayTrace(Tracer tracer, List spans) { // Link to parent context if available String parentSpanId = sd.getParentSpanContext().getSpanId(); + // Determine the base context to use for this span (used both for setParent and for + // storing in the map so that grandchildren inherit the full ancestor chain). + Context effectiveParentCtx; if (sd.getParentSpanContext().isValid()) { - Context parentCtx = spanContextMap.get(parentSpanId); - if (parentCtx != null) { - builder.setParent(parentCtx); + Context localParentCtx = spanContextMap.get(parentSpanId); + if (localParentCtx != null) { + // Parent was replayed in this batch — use its stored context so the full + // ancestor chain is preserved for grandchildren. + effectiveParentCtx = localParentCtx; + builder.setParent(effectiveParentCtx); } else { - // Parent not in this batch — create a remote parent context - builder.setParent(Context.current().with(Span.wrap(sd.getParentSpanContext()))); + // Parent not in this batch — create a remote parent context. + effectiveParentCtx = + Context.current().with(Span.wrap(sd.getParentSpanContext())); + builder.setParent(effectiveParentCtx); } } else { + effectiveParentCtx = Context.root(); builder.setNoParent(); } @@ -155,8 +164,10 @@ static void replayTrace(Tracer tracer, List spans) { span.setStatus(StatusCode.OK, sd.getStatus().getDescription()); } - // Store the context for potential children - Context ctx = Context.current().with(span); + // Store the context for potential children, nested within the effective parent context + // so that grandchildren see the full ancestor chain (e.g. for braintrust.parent + // propagation via BraintrustSpanProcessor.onStart). + Context ctx = effectiveParentCtx.with(span); spanContextMap.put(sd.getSpanContext().getSpanId(), ctx); // End with original end timestamp diff --git a/braintrust-java-agent/internal/src/test/java/dev/braintrust/agent/dd/DDSpanConverterTest.java b/braintrust-java-agent/internal/src/test/java/dev/braintrust/agent/dd/DDSpanConverterTest.java index ca70f29d..71e96fab 100644 --- a/braintrust-java-agent/internal/src/test/java/dev/braintrust/agent/dd/DDSpanConverterTest.java +++ b/braintrust-java-agent/internal/src/test/java/dev/braintrust/agent/dd/DDSpanConverterTest.java @@ -257,6 +257,162 @@ void spanNameFromResourceName() { // ── replayTrace with topological sort ────────────────────────────────────── + /** + * Regression test for the three-span DD trace scenario: + * + *
+     *   span0 (root):  servlet.request/GET /hello          p_id=0
+     *   span1:         spring.handler/HelloController.hello p_id=span0
+     *   span2:         internal/bt-hello-endpoint           p_id=span1  (has braintrust.* tags)
+     * 
+ * + *

All three spans must be replayed, and the parent-child chain must be intact so that span2 + * is a grandchild of the root (span0 → span1 → span2). Previously, the stored context for span1 + * was not nested within span0's context, causing grandchild context propagation to break. + */ + @Test + void replayTraceThreeSpanChainPreservesFullAncestorChain() { + var exporter = InMemorySpanExporter.create(); + var tracerProvider = + SdkTracerProvider.builder() + .setIdGenerator(OverridableIdGenerator.INSTANCE) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + var tracer = tracerProvider.get("test"); + + // IDs derived from the reported DD trace (converted to OTel hex format) + String traceId = "36a36890bcfbfa000000000000000000"; // t_id=3947296854096260224 (padded) + String span0Id = "2440eba0d8e703fa"; // s_id=2613412480130368506 + String span1Id = "595e2b47e54cac29"; // s_id=6434253942510509801 + String span2Id = "2590fcf5a2b71400"; // s_id=2707212214605045248 + + long now = 1_700_000_000_000_000_000L; + + var ctx0 = + io.opentelemetry.api.trace.SpanContext.create( + traceId, + span0Id, + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + var ctx1 = + io.opentelemetry.api.trace.SpanContext.create( + traceId, + span1Id, + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + var ctx2 = + io.opentelemetry.api.trace.SpanContext.create( + traceId, + span2Id, + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + + Map braintrustTags = new HashMap<>(); + braintrustTags.put("braintrust.input", "GET /hello"); + braintrustTags.put("braintrust.name", "hello-request"); + braintrustTags.put("braintrust.output", "Hello, World!"); + + // span0: root servlet span (no braintrust tags) + var span0Data = + new ImmutableSpanData( + ctx0, + io.opentelemetry.api.trace.SpanContext.getInvalid(), + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "GET /hello", + SpanKind.SERVER, + now, + now + 2_174_213_333L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + // span1: spring handler span, child of span0 + var span1Data = + new ImmutableSpanData( + ctx1, + ctx0, + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "HelloController.hello", + SpanKind.SERVER, + now + 1_000_000L, + now + 1_000_000L + 7_459_500L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + // span2: braintrust internal span, grandchild (child of span1) + var span2Attrs = + io.opentelemetry.api.common.Attributes.builder() + .put("braintrust.input", "GET /hello") + .put("braintrust.name", "hello-request") + .put("braintrust.output", "Hello, World!") + .build(); + var span2Data = + new ImmutableSpanData( + ctx2, + ctx1, + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "hello-request", + SpanKind.INTERNAL, + now + 2_000_000L, + now + 2_000_000L + 35_666L, + span2Attrs, + io.opentelemetry.sdk.trace.data.StatusData.unset()); + + // Provide spans in original order (0, 1, 2) — topological sort must handle this correctly. + DDSpanConverter.replayTrace(tracer, List.of(span0Data, span1Data, span2Data)); + + var exported = exporter.getFinishedSpanItems(); + assertEquals(3, exported.size(), "all three spans must be replayed"); + + var replayedSpan0 = + exported.stream().filter(s -> s.getName().equals("GET /hello")).findFirst().get(); + var replayedSpan1 = + exported.stream() + .filter(s -> s.getName().equals("HelloController.hello")) + .findFirst() + .get(); + var replayedSpan2 = + exported.stream() + .filter(s -> s.getName().equals("hello-request")) + .findFirst() + .get(); + + // All spans share the same trace ID. + assertEquals(traceId, replayedSpan0.getSpanContext().getTraceId(), "span0 traceId"); + assertEquals(traceId, replayedSpan1.getSpanContext().getTraceId(), "span1 traceId"); + assertEquals(traceId, replayedSpan2.getSpanContext().getTraceId(), "span2 traceId"); + + // Original span IDs are preserved. + assertEquals(span0Id, replayedSpan0.getSpanContext().getSpanId(), "span0 spanId"); + assertEquals(span1Id, replayedSpan1.getSpanContext().getSpanId(), "span1 spanId"); + assertEquals(span2Id, replayedSpan2.getSpanContext().getSpanId(), "span2 spanId"); + + // span0 is the root — no parent. + assertFalse(replayedSpan0.getParentSpanContext().isValid(), "span0 must have no parent"); + + // span1's parent is span0. + assertTrue(replayedSpan1.getParentSpanContext().isValid(), "span1 must have a parent"); + assertEquals( + span0Id, + replayedSpan1.getParentSpanContext().getSpanId(), + "span1 parent must be span0"); + + // span2's parent is span1 (grandchild of span0). + assertTrue(replayedSpan2.getParentSpanContext().isValid(), "span2 must have a parent"); + assertEquals( + span1Id, + replayedSpan2.getParentSpanContext().getSpanId(), + "span2 parent must be span1"); + + // span2's braintrust attributes must be preserved. + var attrs2 = replayedSpan2.getAttributes(); + assertEquals("GET /hello", attrs2.get(AttributeKey.stringKey("braintrust.input"))); + assertEquals("hello-request", attrs2.get(AttributeKey.stringKey("braintrust.name"))); + assertEquals("Hello, World!", attrs2.get(AttributeKey.stringKey("braintrust.output"))); + + tracerProvider.close(); + } + @Test void replayTracePreservesParentChildOrder() { var exporter = InMemorySpanExporter.create(); @@ -350,6 +506,450 @@ void replayTracePreservesParentChildOrder() { tracerProvider.close(); } + // ── replayTrace edge cases ───────────────────────────────────────────────── + + @Test + void replayTraceNullListIsNoop() { + var exporter = InMemorySpanExporter.create(); + var tracerProvider = + SdkTracerProvider.builder() + .setIdGenerator(OverridableIdGenerator.INSTANCE) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + var tracer = tracerProvider.get("test"); + + assertDoesNotThrow(() -> DDSpanConverter.replayTrace(tracer, null)); + assertEquals(0, exporter.getFinishedSpanItems().size()); + + tracerProvider.close(); + } + + @Test + void replayTraceEmptyListIsNoop() { + var exporter = InMemorySpanExporter.create(); + var tracerProvider = + SdkTracerProvider.builder() + .setIdGenerator(OverridableIdGenerator.INSTANCE) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + var tracer = tracerProvider.get("test"); + + assertDoesNotThrow(() -> DDSpanConverter.replayTrace(tracer, List.of())); + assertEquals(0, exporter.getFinishedSpanItems().size()); + + tracerProvider.close(); + } + + @Test + void replayTraceSingleSpanNoParent() { + var exporter = InMemorySpanExporter.create(); + var tracerProvider = + SdkTracerProvider.builder() + .setIdGenerator(OverridableIdGenerator.INSTANCE) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + var tracer = tracerProvider.get("test"); + + var spanCtx = + io.opentelemetry.api.trace.SpanContext.create( + "abcdef1234567890abcdef1234567890", + "1234567890abcdef", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + long now = 1_700_000_000_000_000_000L; + var spanData = + new ImmutableSpanData( + spanCtx, + io.opentelemetry.api.trace.SpanContext.getInvalid(), + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "lone-span", + SpanKind.INTERNAL, + now, + now + 1_000_000L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + + DDSpanConverter.replayTrace(tracer, List.of(spanData)); + + var exported = exporter.getFinishedSpanItems(); + assertEquals(1, exported.size()); + assertEquals("lone-span", exported.get(0).getName()); + assertEquals( + "abcdef1234567890abcdef1234567890", exported.get(0).getSpanContext().getTraceId()); + assertEquals("1234567890abcdef", exported.get(0).getSpanContext().getSpanId()); + assertFalse(exported.get(0).getParentSpanContext().isValid()); + + tracerProvider.close(); + } + + /** + * A batch may contain two unrelated root spans (e.g. from concurrent requests). Both must be + * replayed independently with no cross-contamination of trace IDs or parent links. + */ + @Test + void replayTraceMultipleIndependentRoots() { + var exporter = InMemorySpanExporter.create(); + var tracerProvider = + SdkTracerProvider.builder() + .setIdGenerator(OverridableIdGenerator.INSTANCE) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + var tracer = tracerProvider.get("test"); + + String traceIdA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + String traceIdB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + var ctxA = + io.opentelemetry.api.trace.SpanContext.create( + traceIdA, + "aaaaaaaaaaaaaaaa", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + var ctxAChild = + io.opentelemetry.api.trace.SpanContext.create( + traceIdA, + "aaaaaaaaaaaaaabb", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + var ctxB = + io.opentelemetry.api.trace.SpanContext.create( + traceIdB, + "bbbbbbbbbbbbbbbb", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + + long now = 1_700_000_000_000_000_000L; + var spanA = + new ImmutableSpanData( + ctxA, + io.opentelemetry.api.trace.SpanContext.getInvalid(), + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "root-A", + SpanKind.SERVER, + now, + now + 1_000_000L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + var spanAChild = + new ImmutableSpanData( + ctxAChild, + ctxA, + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "child-A", + SpanKind.INTERNAL, + now + 100_000L, + now + 900_000L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + var spanB = + new ImmutableSpanData( + ctxB, + io.opentelemetry.api.trace.SpanContext.getInvalid(), + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "root-B", + SpanKind.SERVER, + now + 500_000L, + now + 2_000_000L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + + DDSpanConverter.replayTrace(tracer, List.of(spanA, spanAChild, spanB)); + + var exported = exporter.getFinishedSpanItems(); + assertEquals(3, exported.size(), "all three spans must be replayed"); + + var replayedA = + exported.stream().filter(s -> s.getName().equals("root-A")).findFirst().get(); + var replayedAChild = + exported.stream().filter(s -> s.getName().equals("child-A")).findFirst().get(); + var replayedB = + exported.stream().filter(s -> s.getName().equals("root-B")).findFirst().get(); + + // Each root keeps its own trace ID. + assertEquals(traceIdA, replayedA.getSpanContext().getTraceId(), "root-A traceId"); + assertEquals(traceIdA, replayedAChild.getSpanContext().getTraceId(), "child-A traceId"); + assertEquals(traceIdB, replayedB.getSpanContext().getTraceId(), "root-B traceId"); + + // root-B must have no parent — it is independent of trace A. + assertFalse(replayedB.getParentSpanContext().isValid(), "root-B must have no parent"); + + // child-A's parent is root-A. + assertTrue(replayedAChild.getParentSpanContext().isValid()); + assertEquals("aaaaaaaaaaaaaaaa", replayedAChild.getParentSpanContext().getSpanId()); + + tracerProvider.close(); + } + + /** + * When spans arrive in fully reversed order (grandchild, child, root), the topological sort + * must still produce the correct parent-before-child ordering. + */ + @Test + void replayTraceReversedInputOrderSortedCorrectly() { + var exporter = InMemorySpanExporter.create(); + var tracerProvider = + SdkTracerProvider.builder() + .setIdGenerator(OverridableIdGenerator.INSTANCE) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + var tracer = tracerProvider.get("test"); + + String traceId = "cccccccccccccccccccccccccccccccc"; + var ctxRoot = + io.opentelemetry.api.trace.SpanContext.create( + traceId, + "1111111111111111", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + var ctxChild = + io.opentelemetry.api.trace.SpanContext.create( + traceId, + "2222222222222222", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + var ctxGrandchild = + io.opentelemetry.api.trace.SpanContext.create( + traceId, + "3333333333333333", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + + long now = 1_700_000_000_000_000_000L; + var root = + new ImmutableSpanData( + ctxRoot, + io.opentelemetry.api.trace.SpanContext.getInvalid(), + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "root", + SpanKind.SERVER, + now, + now + 3_000_000L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + var child = + new ImmutableSpanData( + ctxChild, + ctxRoot, + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "child", + SpanKind.INTERNAL, + now + 500_000L, + now + 2_500_000L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + var grandchild = + new ImmutableSpanData( + ctxGrandchild, + ctxChild, + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "grandchild", + SpanKind.CLIENT, + now + 1_000_000L, + now + 2_000_000L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + + // Provide in fully reversed order: grandchild, child, root. + DDSpanConverter.replayTrace(tracer, List.of(grandchild, child, root)); + + var exported = exporter.getFinishedSpanItems(); + assertEquals(3, exported.size(), "all three spans must be replayed"); + + var replayedRoot = + exported.stream().filter(s -> s.getName().equals("root")).findFirst().get(); + var replayedChild = + exported.stream().filter(s -> s.getName().equals("child")).findFirst().get(); + var replayedGrandchild = + exported.stream().filter(s -> s.getName().equals("grandchild")).findFirst().get(); + + // IDs preserved. + assertEquals("1111111111111111", replayedRoot.getSpanContext().getSpanId()); + assertEquals("2222222222222222", replayedChild.getSpanContext().getSpanId()); + assertEquals("3333333333333333", replayedGrandchild.getSpanContext().getSpanId()); + + // All share the same trace ID. + assertEquals(traceId, replayedRoot.getSpanContext().getTraceId()); + assertEquals(traceId, replayedChild.getSpanContext().getTraceId()); + assertEquals(traceId, replayedGrandchild.getSpanContext().getTraceId()); + + // Parent links correct. + assertFalse(replayedRoot.getParentSpanContext().isValid(), "root has no parent"); + assertEquals( + "1111111111111111", + replayedChild.getParentSpanContext().getSpanId(), + "child parent = root"); + assertEquals( + "2222222222222222", + replayedGrandchild.getParentSpanContext().getSpanId(), + "grandchild parent = child"); + + tracerProvider.close(); + } + + /** + * When a child's parent span is NOT in the current batch (distributed/remote parent), the + * replayed child must still carry the remote parent's span ID in its parentSpanContext. + */ + @Test + void replayTraceRemoteParentNotInBatch() { + var exporter = InMemorySpanExporter.create(); + var tracerProvider = + SdkTracerProvider.builder() + .setIdGenerator(OverridableIdGenerator.INSTANCE) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + var tracer = tracerProvider.get("test"); + + String traceId = "dddddddddddddddddddddddddddddddd"; + // remoteParentCtx represents a span from an upstream service — not in this batch. + var remoteParentCtx = + io.opentelemetry.api.trace.SpanContext.create( + traceId, + "eeeeeeeeeeeeeeee", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + var localSpanCtx = + io.opentelemetry.api.trace.SpanContext.create( + traceId, + "ffffffffffffffff", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + + long now = 1_700_000_000_000_000_000L; + var localSpan = + new ImmutableSpanData( + localSpanCtx, + remoteParentCtx, // parent is NOT in this batch + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "local-root", + SpanKind.SERVER, + now, + now + 1_000_000L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.unset()); + + DDSpanConverter.replayTrace(tracer, List.of(localSpan)); + + var exported = exporter.getFinishedSpanItems(); + assertEquals(1, exported.size()); + var replayed = exported.get(0); + + assertEquals("ffffffffffffffff", replayed.getSpanContext().getSpanId(), "spanId preserved"); + assertEquals(traceId, replayed.getSpanContext().getTraceId(), "traceId preserved"); + // The remote parent context must still be attached. + assertTrue(replayed.getParentSpanContext().isValid(), "remote parent must be valid"); + assertEquals( + "eeeeeeeeeeeeeeee", + replayed.getParentSpanContext().getSpanId(), + "remote parent spanId preserved"); + + tracerProvider.close(); + } + + /** + * Error status on a span must survive the full replayTrace round-trip and appear on the + * exported span. + */ + @Test + void replayTraceErrorStatusPreserved() { + var exporter = InMemorySpanExporter.create(); + var tracerProvider = + SdkTracerProvider.builder() + .setIdGenerator(OverridableIdGenerator.INSTANCE) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + var tracer = tracerProvider.get("test"); + + var spanCtx = + io.opentelemetry.api.trace.SpanContext.create( + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "eeeeeeeeeeeeeeee", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + long now = 1_700_000_000_000_000_000L; + var spanData = + new ImmutableSpanData( + spanCtx, + io.opentelemetry.api.trace.SpanContext.getInvalid(), + io.opentelemetry.sdk.resources.Resource.getDefault(), + io.opentelemetry.sdk.common.InstrumentationScopeInfo.create("test"), + "error-span", + SpanKind.INTERNAL, + now, + now + 500_000L, + io.opentelemetry.api.common.Attributes.empty(), + io.opentelemetry.sdk.trace.data.StatusData.create( + StatusCode.ERROR, "something broke")); + + DDSpanConverter.replayTrace(tracer, List.of(spanData)); + + var exported = exporter.getFinishedSpanItems(); + assertEquals(1, exported.size()); + assertEquals(StatusCode.ERROR, exported.get(0).getStatus().getStatusCode()); + + tracerProvider.close(); + } + + // ── Tag edge cases ───────────────────────────────────────────────────────── + + @Test + void nullTagValueIsSkipped() { + Map tags = new HashMap<>(); + tags.put("null.tag", null); + tags.put("keep.tag", "present"); + var ddSpan = stubSpan("span", "internal", 1_000_000_000L, 0L, tags, false); + DDTraceId traceId = DDTraceId.from(1L); + + SpanData result = + assertDoesNotThrow(() -> DDSpanConverter.convertSpan(ddSpan, traceId, 1L, 0L)); + + var attrs = result.getAttributes(); + // null-valued tag must not appear (and must not throw) + assertNull(attrs.get(AttributeKey.stringKey("null.tag"))); + assertEquals("present", attrs.get(AttributeKey.stringKey("keep.tag"))); + } + + @Test + void unknownTagTypeConvertedViaToString() { + // A custom object type falls through to the toString() fallback. + Object customValue = + new Object() { + @Override + public String toString() { + return "custom-value"; + } + }; + Map tags = new HashMap<>(); + tags.put("custom.tag", customValue); + var ddSpan = stubSpan("span", "internal", 1_000_000_000L, 0L, tags, false); + DDTraceId traceId = DDTraceId.from(1L); + + SpanData result = DDSpanConverter.convertSpan(ddSpan, traceId, 1L, 0L); + + assertEquals( + "custom-value", result.getAttributes().get(AttributeKey.stringKey("custom.tag"))); + } + + @Test + void nullTagsMapProducesEmptyAttributes() { + // MutableSpan.getTags() returning null must not throw. + var ddSpan = stubSpan("span", "internal", 1_000_000_000L, 0L, null, false); + DDTraceId traceId = DDTraceId.from(1L); + + SpanData result = + assertDoesNotThrow(() -> DDSpanConverter.convertSpan(ddSpan, traceId, 1L, 0L)); + + assertEquals(0, result.getAttributes().size()); + } + // ── Stub helper ──────────────────────────────────────────────────────────── private static MutableSpan stubSpan( diff --git a/braintrust-otel-extension/build.gradle b/braintrust-otel-extension/build.gradle index fa0fb9fc..bb88ac07 100644 --- a/braintrust-otel-extension/build.gradle +++ b/braintrust-otel-extension/build.gradle @@ -25,6 +25,14 @@ evaluationDependsOn(':braintrust-sdk') dependencies { implementation project(':braintrust-sdk') + + // OTel SDK + autoconfigure SPI — needed to compile OtelAutoConfig. + // At runtime these are provided by the OTel Java agent, so they are compileOnly. + compileOnly "io.opentelemetry:opentelemetry-sdk:${otelVersion}" + compileOnly "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:${otelVersion}" + + // SLF4J API — needed at compile time for Lombok's @Slf4j annotation. + compileOnly "org.slf4j:slf4j-api:${slf4jVersion}" } jar { diff --git a/braintrust-sdk/src/main/java/dev/braintrust/OtelAutoConfig.java b/braintrust-otel-extension/src/main/java/dev/braintrust/OtelAutoConfig.java similarity index 100% rename from braintrust-sdk/src/main/java/dev/braintrust/OtelAutoConfig.java rename to braintrust-otel-extension/src/main/java/dev/braintrust/OtelAutoConfig.java