Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 0 additions & 65 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,68 +20,3 @@ Because the SDK is new and under active development, third-party contribution be
## Development

See [AGENTS.md](./AGENTS.md) for best practices developing, testing, and releasing the SDK.

## Dependency security alerts

`.github/workflows/dependency-submission.yml` submits the shipped dependency graph
on pushes to `main`, or through a manual run on the default branch. It does not
enable Dependabot update PRs.

The inventory includes the SDK's runtime and embedded inputs, the OTel extension's
runtime dependencies, and the Java agent's bootstrap and internal packaging inputs.
The agent's internal module is also scanned directly because its shaded JAR hides
its bundled dependencies from the outer agent's dependency graph.

Only these packaging projects and configurations contribute to the inventory.
Dependencies used solely by tests, examples, build tooling, or compile-only
instrumentation targets are excluded. Transitive dependencies that ship are still
included, and submitted dependencies are marked as runtime. This is a shipped-product
inventory, not a security inventory of everything executed during development or CI.

The workflow and local scanner share the project/configuration filters and graph
plugin version in `.github/dependency-graph.json`. When changing JAR assembly or
adding a published artifact, update those filters to cover its dependency inputs.

### Checking the current branch locally

Install Python 3.9+, JDK 17, and the GitHub CLI, then authenticate with `gh auth login`.
From the repository root, run:

```bash
./scripts/check-dependencies.py
```

This resolves the current working tree's shipped dependencies, including uncommitted
build-file changes, and queries GitHub's reviewed advisory database for each resolved
version. It includes transitive dependencies and prints the affected package/version,
severity, CVE or GHSA identifier, and advisory URL for each finding.
The shared configuration is authoritative: inherited dependency-graph environment
variables and JVM system properties are ignored, including exclusion filters.

Exit codes:

- `0`: no matching GitHub-reviewed advisories.
- `1`: vulnerable dependency versions found.
- `2`: incomplete scan, such as a dependency resolution, authentication, or network
failure. Fix the error and rerun; this is not a clean result.

The scanner requires network access to resolve dependencies and query GitHub. It does
not submit a dependency graph, modify Dependabot alerts, build/test the SDK, or change
dependency versions. Temporary reports are removed automatically. A clean result
only covers known reviewed advisories for the shipped inventory, not excluded
development dependencies or whether an individual vulnerability is exploitable.

### Switching from automatic dependency submission

1. Merge the workflow to `main` and confirm **Shipped dependency submission** succeeds.
2. Check **Insights → Dependency graph** for the filtered inventory. It should retain
Jackson, Byte Buddy, and the agent's OTel dependencies, without test-only frameworks.
3. Under **Settings → Advanced Security → Dependency graph**, disable **Automatic
dependency submission** to stop the redundant, unfiltered submission job. Leave
the dependency graph and Dependabot alerts enabled. Security-update PRs can remain
disabled.

The workflow saves its generated JSON snapshot as an Actions artifact for inspection.
GitHub gives explicit workflow submissions precedence over automatic submissions for
the same manifest, so the filtered inventory can be verified before disabling the
automatic job.
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

/** Globally available bootstrap classpath resource class */
public class BraintrustBridge {
public static final String INSTRUMENTATION_NAME = "braintrust-java";

/**
* Diagnostic utility tracking the number of times braintrust otel has been installed.
*
Expand Down
8 changes: 8 additions & 0 deletions braintrust-sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ subprojects { subproject ->
// Make them available at compile time so instrumentation modules can reference them.
dependencies.add('compileOnly', project(':braintrust-java-agent:bootstrap'))

// Derive provenance expectations from muzzle, not from the Java instrumentation constants.
def minimumMuzzleVersion = subproject.extensions.getByName('muzzle').minimumVersion
tasks.withType(Test).configureEach {
inputs.property 'muzzleMinimumVersion', minimumMuzzleVersion
systemProperty 'braintrust.muzzle.minimumVersion', minimumMuzzleVersion
}

// --- $Muzzle side-class generation (compile-time) ---

// Configuration for the muzzle generator classpath
Expand Down Expand Up @@ -145,6 +152,7 @@ dependencies {

testImplementation "org.slf4j:slf4j-simple:${slf4jVersion}"
testImplementation "io.opentelemetry:opentelemetry-sdk-testing:${otelVersion}"
testImplementation 'io.opentelemetry.proto:opentelemetry-proto:1.11.0-alpha'
testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}"
testImplementation "org.junit.jupiter:junit-jupiter-params:${junitVersion}"
testImplementation 'org.wiremock:wiremock:3.13.1'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.anthropic.core.ClientOptions;
import com.anthropic.core.http.HttpClient;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Tracer;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.function.Consumer;
Expand All @@ -13,10 +14,15 @@
/** Braintrust Anthropic client instrumentation. */
@Slf4j
public final class BraintrustAnthropic {
static final String INSTRUMENTATION_NAME = "anthropic";
static final String INSTRUMENTATION_VERSION = "2.2.0";

/** Instrument Anthropic client with Braintrust traces. */
public static AnthropicClient wrap(OpenTelemetry openTelemetry, AnthropicClient client) {
if (!instrument(openTelemetry, client)) {
if (!instrument(
openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION),
client,
false)) {
return client;
}
return ContextCapturingProxy.wrap(client, AnthropicClient.class);
Expand All @@ -25,7 +31,29 @@ public static AnthropicClient wrap(OpenTelemetry openTelemetry, AnthropicClient
/** Instrument an async Anthropic client with Braintrust traces. */
public static AnthropicClientAsync wrap(
OpenTelemetry openTelemetry, AnthropicClientAsync client) {
if (!instrument(openTelemetry, client)) {
if (!instrument(
openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION),
client,
false)) {
return client;
}
return ContextCapturingProxy.wrap(client, AnthropicClientAsync.class);
}

/**
* Instruments a client using the owning library's tracer, replacing any existing provider
* tracer without adding another tracing layer.
*/
public static AnthropicClient wrap(Tracer tracer, AnthropicClient client) {
if (!instrument(tracer, client, true)) {
return client;
}
return ContextCapturingProxy.wrap(client, AnthropicClient.class);
}

/** Async counterpart of {@link #wrap(Tracer, AnthropicClient)}. */
public static AnthropicClientAsync wrap(Tracer tracer, AnthropicClientAsync client) {
if (!instrument(tracer, client, true)) {
return client;
}
return ContextCapturingProxy.wrap(client, AnthropicClientAsync.class);
Expand All @@ -40,13 +68,15 @@ public static AnthropicClientAsync wrap(
* proxy's internal context header is only stripped by {@link TracingHttpClient}, so
* installing it without one would leak trace/span IDs to the provider.
*/
private static boolean instrument(OpenTelemetry openTelemetry, Object client) {
private static boolean instrument(Tracer tracer, Object client, boolean replaceTracer) {
if (ContextCapturingProxy.isContextCapturingProxy(client)) {
// already instrumented
return true;
if (!replaceTracer) {
return true;
}
client = ContextCapturingProxy.unwrap(client);
}
try {
instrumentHttpClient(openTelemetry, client);
instrumentHttpClient(tracer, client, replaceTracer);
return true;
} catch (Exception e) {
log.error(
Expand All @@ -57,14 +87,14 @@ private static boolean instrument(OpenTelemetry openTelemetry, Object client) {
}
}

private static void instrumentHttpClient(OpenTelemetry openTelemetry, Object client) {
private static void instrumentHttpClient(Tracer tracer, Object client, boolean replaceTracer) {
int[] instrumented = {0};
forAllFields(
client,
fieldName -> {
try {
if (getField(client, fieldName) instanceof ClientOptions clientOptions) {
instrumentClientOptions(openTelemetry, clientOptions);
instrumentClientOptions(tracer, clientOptions, replaceTracer);
instrumented[0]++;
}
} catch (ReflectiveOperationException e) {
Expand All @@ -83,18 +113,22 @@ private static void instrumentHttpClient(OpenTelemetry openTelemetry, Object cli

/** Swaps both HTTP client fields on a {@link ClientOptions} for tracing wrappers. */
private static void instrumentClientOptions(
OpenTelemetry openTelemetry, ClientOptions clientOptions) {
swapHttpClient(openTelemetry, clientOptions, "originalHttpClient");
swapHttpClient(openTelemetry, clientOptions, "httpClient");
Tracer tracer, ClientOptions clientOptions, boolean replaceTracer) {
swapHttpClient(tracer, clientOptions, "originalHttpClient", replaceTracer);
swapHttpClient(tracer, clientOptions, "httpClient", replaceTracer);
}

private static void swapHttpClient(
OpenTelemetry openTelemetry, ClientOptions clientOptions, String fieldName) {
Tracer tracer, ClientOptions clientOptions, String fieldName, boolean replaceTracer) {
try {
HttpClient httpClient = getField(clientOptions, fieldName);
if (!(httpClient instanceof TracingHttpClient)) {
if (httpClient instanceof TracingHttpClient tracing) {
if (replaceTracer) {
setPrivateField(clientOptions, fieldName, tracing.withTracer(tracer));
}
} else {
setPrivateField(
clientOptions, fieldName, new TracingHttpClient(openTelemetry, httpClient));
clientOptions, fieldName, new TracingHttpClient(tracer, httpClient));
}
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ static boolean isContextCapturingProxy(Object o) {
&& Proxy.getInvocationHandler(o) instanceof ContextCapturingProxy;
}

static Object unwrap(Object proxy) {
do {
proxy = ((ContextCapturingProxy) Proxy.getInvocationHandler(proxy)).delegate;
} while (isContextCapturingProxy(proxy));
return proxy;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// equals/hashCode/toString are the only Object methods routed to an InvocationHandler.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.anthropic.core.http.HttpRequest;
import com.anthropic.core.http.HttpRequestBody;
import com.anthropic.core.http.HttpResponse;
import dev.braintrust.bootstrap.BraintrustBridge;
import dev.braintrust.instrumentation.InstrumentationSemConv;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
Expand Down Expand Up @@ -34,10 +33,22 @@ public class TracingHttpClient implements HttpClient {
private final HttpClient underlying;

public TracingHttpClient(OpenTelemetry openTelemetry, HttpClient underlying) {
this.tracer = openTelemetry.getTracer(BraintrustBridge.INSTRUMENTATION_NAME);
this(
openTelemetry.getTracer(
BraintrustAnthropic.INSTRUMENTATION_NAME,
BraintrustAnthropic.INSTRUMENTATION_VERSION),
underlying);
}

TracingHttpClient(Tracer tracer, HttpClient underlying) {
this.tracer = tracer;
this.underlying = underlying;
}

TracingHttpClient withTracer(Tracer tracer) {
return this.tracer == tracer ? this : new TracingHttpClient(tracer, underlying);
}

/**
* Starts the LLM span. anthropic-java (and frameworks like Spring AI 2.x) dispatch
* async/streaming requests on executors where the caller's thread-local context is lost — which
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ void beforeEach() {
testHarness = TestHarness.setup();
}

@SneakyThrows
private static void assertInstrumentationOrigin(io.opentelemetry.sdk.trace.data.SpanData span) {
JsonNode instrumentation =
JSON_MAPPER
.readTree(
span.getAttributes()
.get(AttributeKey.stringKey("braintrust.context_json")))
.path("span_origin")
.path("instrumentation");
assertEquals("anthropic", instrumentation.path("name").asText());
assertEquals(
System.getProperty("braintrust.muzzle.minimumVersion"),
instrumentation.path("version").asText(),
"span origin version must match the minimum passing muzzle version");
}

@Test
@SneakyThrows
void testWrapAnthropic() {
Expand Down Expand Up @@ -77,6 +93,7 @@ void testWrapAnthropic() {
var spans = testHarness.awaitExportedSpans();
assertEquals(1, spans.size());
var span = spans.get(0);
assertInstrumentationOrigin(span);

assertFalse(span.getName().isEmpty(), "span name should be non-empty");

Expand Down Expand Up @@ -166,6 +183,7 @@ void testWrapAnthropicStreaming() {
var spans = testHarness.awaitExportedSpans();
assertEquals(1, spans.size());
var span = spans.get(0);
assertInstrumentationOrigin(span);

assertFalse(span.getName().isEmpty(), "span name should be non-empty");

Expand Down Expand Up @@ -243,6 +261,7 @@ void testDirectAsyncClientParenting() {
assertEquals(2, spans.size());
var llmSpan =
spans.stream().filter(s -> !"foo".equals(s.getName())).findFirst().orElseThrow();
assertInstrumentationOrigin(llmSpan);
assertEquals(
parentSpan.getSpanContext().getTraceId(),
llmSpan.getTraceId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
*/
@Slf4j
class BraintrustBedrockInterceptor implements ExecutionInterceptor {
private static final String INSTRUMENTATION_NAME = "braintrust-aws-bedrock";
private static final String INSTRUMENTATION_NAME = "aws-bedrock";
private static final String INSTRUMENTATION_VERSION = "2.30.0";

private static final ExecutionAttribute<Span> SPAN_ATTRIBUTE =
new ExecutionAttribute<>("braintrust.span");
Expand All @@ -49,7 +50,7 @@ class BraintrustBedrockInterceptor implements ExecutionInterceptor {
private final Tracer tracer;

BraintrustBedrockInterceptor(OpenTelemetry openTelemetry) {
this.tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME);
this.tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION);
}

private static final Set<String> INSTRUMENTED_OPERATIONS = Set.of("Converse", "ConverseStream");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ void converseProducesLlmSpan(String modelId) {
var spans = testHarness.awaitExportedSpans(1);
assertEquals(1, spans.size(), "expected exactly one span");
var span = spans.get(0);
var instrumentation =
JSON_MAPPER
.readTree(
span.getAttributes()
.get(AttributeKey.stringKey("braintrust.context_json")))
.path("span_origin")
.path("instrumentation");
assertEquals("aws-bedrock", instrumentation.path("name").asText());
assertEquals(
System.getProperty("braintrust.muzzle.minimumVersion"),
instrumentation.path("version").asText(),
"span origin version must match the minimum passing muzzle version");

String spanAttributesJson =
span.getAttributes().get(AttributeKey.stringKey("braintrust.span_attributes"));
Expand Down Expand Up @@ -135,6 +147,18 @@ void converseStreamProducesLlmSpan() {
var spans = testHarness.awaitExportedSpans(1);
assertEquals(1, spans.size(), "expected exactly one span");
var span = spans.get(0);
var instrumentation =
JSON_MAPPER
.readTree(
span.getAttributes()
.get(AttributeKey.stringKey("braintrust.context_json")))
.path("span_origin")
.path("instrumentation");
assertEquals("aws-bedrock", instrumentation.path("name").asText());
assertEquals(
System.getProperty("braintrust.muzzle.minimumVersion"),
instrumentation.path("version").asText(),
"span origin version must match the minimum passing muzzle version");

String spanAttributesJson =
span.getAttributes().get(AttributeKey.stringKey("braintrust.span_attributes"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import static dev.braintrust.json.BraintrustJsonMapper.toJson;

import com.google.genai.types.HttpOptions;
import dev.braintrust.bootstrap.BraintrustBridge;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
Expand All @@ -30,6 +29,9 @@
*/
@Slf4j
class BraintrustApiClient extends ApiClient {
private static final String INSTRUMENTATION_NAME = "genai";
private static final String INSTRUMENTATION_VERSION = "1.18.0";

private final ApiClient delegate;
private final Tracer tracer;

Expand All @@ -44,7 +46,7 @@ public BraintrustApiClient(ApiClient delegate, OpenTelemetry openTelemetry) {
delegate.httpOptions != null ? Optional.of(delegate.httpOptions) : Optional.empty(),
delegate.clientOptions != null ? delegate.clientOptions : Optional.empty());
this.delegate = delegate;
this.tracer = openTelemetry.getTracer(BraintrustBridge.INSTRUMENTATION_NAME);
this.tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION);
}

private void tagSpan(
Expand Down
Loading
Loading