Skip to content

Commit 5b7070c

Browse files
committed
feature: traced scorers
1 parent a4f278c commit 5b7070c

10 files changed

Lines changed: 1226 additions & 222 deletions

File tree

braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustOpenApiClient.java

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,18 @@ public BtqlQueryResponse btqlQuery(String query) {
145145
getHttpClient()
146146
.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
147147

148-
if (response.statusCode() / 100 != 2) {
149-
throw new RuntimeException(
150-
"BTQL query failed with status "
151-
+ response.statusCode()
152-
+ ": "
153-
+ response.body());
148+
if (response.statusCode() == 429) {
149+
throw new BtqlRateLimitException(
150+
response.statusCode(),
151+
"BTQL rate limit exceeded",
152+
response.headers(),
153+
response.body());
154+
} else if (response.statusCode() / 100 != 2) {
155+
throw new ApiException(
156+
response.statusCode(),
157+
"BTQL query failed",
158+
response.headers(),
159+
response.body());
154160
}
155161

156162
return MAPPER.readValue(response.body(), BtqlQueryResponse.class);
@@ -163,7 +169,38 @@ public BtqlQueryResponse btqlQuery(String query) {
163169

164170
public record OrgInfo(String id, String name) {}
165171

166-
public record BtqlQueryResponse(List<Map<String, Object>> data) {}
172+
/**
173+
* Response from a {@code POST /btql} query.
174+
*
175+
* <p>Freshness is determined by comparing {@link FreshnessState#lastProcessedXactId()} to
176+
* {@link FreshnessState#lastConsideredXactId()}: when both are non-null and equal, the query
177+
* has caught up to all ingested data and the result is fresh.
178+
*
179+
* <p>The {@link RealtimeState#type()} field indicates whether realtime indexing is still active
180+
* ({@code "on"}) or has timed out ({@code "exhausted_timeout"}).
181+
*/
182+
public record BtqlQueryResponse(
183+
List<Map<String, Object>> data,
184+
@JsonProperty("freshness_state") FreshnessState freshnessState,
185+
@JsonProperty("realtime_state") RealtimeState realtimeState) {
186+
187+
/** Returns {@code true} when the query result has caught up to all ingested data. */
188+
public boolean isFresh() {
189+
if (freshnessState == null) {
190+
return false;
191+
}
192+
var processed = freshnessState.lastProcessedXactId();
193+
var considered = freshnessState.lastConsideredXactId();
194+
return processed != null && processed.equals(considered);
195+
}
196+
}
197+
198+
public record FreshnessState(
199+
@JsonProperty("last_processed_xact_id") String lastProcessedXactId,
200+
@JsonProperty("last_considered_xact_id") String lastConsideredXactId) {}
201+
202+
/** Real-time indexing state for a BTQL query. */
203+
public record RealtimeState(@JsonProperty("type") String type) {}
167204

168205
private record LoginRequest(String token) {}
169206

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package dev.braintrust.api;
2+
3+
import dev.braintrust.openapi.ApiException;
4+
import java.net.http.HttpHeaders;
5+
6+
/** Thrown when the BTQL endpoint returns HTTP 429 (Too Many Requests). */
7+
public final class BtqlRateLimitException extends ApiException {
8+
BtqlRateLimitException(
9+
int code, String message, HttpHeaders responseHeaders, String responseBody) {
10+
super(code, message, responseHeaders, responseBody);
11+
}
12+
}

braintrust-sdk/src/main/java/dev/braintrust/eval/Eval.java

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import dev.braintrust.openapi.api.ExperimentsApi;
1010
import dev.braintrust.openapi.model.CreateExperiment;
1111
import dev.braintrust.openapi.model.Project;
12+
import dev.braintrust.trace.BrainstoreTrace;
1213
import dev.braintrust.trace.BraintrustContext;
1314
import dev.braintrust.trace.BraintrustTracing;
1415
import io.opentelemetry.api.common.AttributeKey;
@@ -124,6 +125,7 @@ private void evalOne(String experimentId, DatasetCase<INPUT, OUTPUT> datasetCase
124125
}
125126
try (var rootScope = BraintrustContext.ofExperiment(experimentId, rootSpan).makeCurrent()) {
126127
final TaskResult<INPUT, OUTPUT> taskResult;
128+
final String taskSpanId;
127129
{ // run task
128130
var taskSpan =
129131
tracer.spanBuilder("task")
@@ -132,6 +134,7 @@ private void evalOne(String experimentId, DatasetCase<INPUT, OUTPUT> datasetCase
132134
"braintrust.span_attributes",
133135
toJson(Map.of("type", "task")))
134136
.startSpan();
137+
taskSpanId = taskSpan.getSpanContext().getSpanId();
135138
try (var unused =
136139
BraintrustContext.ofExperiment(experimentId, taskSpan).makeCurrent()) {
137140
taskResult = task.apply(datasetCase, parameters);
@@ -155,32 +158,48 @@ private void evalOne(String experimentId, DatasetCase<INPUT, OUTPUT> datasetCase
155158
}
156159
taskSpan.end();
157160
}
161+
162+
// Create a single BrainstoreTrace for this eval case, shared across all scorers.
163+
// It fetches spans lazily on first access (only if a TracedScorer actually calls it).
164+
// We wait specifically for the task span to appear, which guarantees its children
165+
// (LLM spans, tool spans) have also been indexed — since children end before parents.
166+
var rootTraceId = rootSpan.getSpanContext().getTraceId();
167+
var trace =
168+
BrainstoreTrace.forExperiment(
169+
client, experimentId, rootTraceId, List.of(taskSpanId));
170+
158171
// run scorers - one span per scorer
159172
for (var scorer : scorers) {
160-
runScorer(experimentId, rootSpan, scorer, taskResult);
173+
runScorer(experimentId, rootSpan, scorer, taskResult, trace);
161174
}
162175
} finally {
163176
rootSpan.end();
164177
}
165178
}
166179

167180
/**
168-
* Runs a scorer against a successful task result. If the scorer throws, falls back to {@link
169-
* Scorer#scoreForScorerException}.
181+
* Runs a scorer against a successful task result. If the scorer is a {@link TracedScorer}, it
182+
* receives the {@link BrainstoreTrace} for the eval case. If the scorer throws, falls back to
183+
* {@link Scorer#scoreForScorerException}.
170184
*/
171185
private void runScorer(
172186
String experimentId,
173187
Span rootSpan,
174188
Scorer<INPUT, OUTPUT> scorer,
175-
TaskResult<INPUT, OUTPUT> taskResult) {
189+
TaskResult<INPUT, OUTPUT> taskResult,
190+
BrainstoreTrace trace) {
176191
var scoreSpan =
177192
tracer.spanBuilder("score")
178193
.setAttribute(PARENT, "experiment_id:" + experimentId)
179194
.startSpan();
180195
try (var unused = BraintrustContext.ofExperiment(experimentId, scoreSpan).makeCurrent()) {
181196
List<Score> scores;
182197
try {
183-
scores = scorer.score(taskResult);
198+
if (scorer instanceof TracedScorer<INPUT, OUTPUT> tracedScorer) {
199+
scores = tracedScorer.score(taskResult, trace);
200+
} else {
201+
scores = scorer.score(taskResult);
202+
}
184203
} catch (Exception e) {
185204
scoreSpan.setStatus(StatusCode.ERROR, e.getMessage());
186205
scoreSpan.recordException(e);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package dev.braintrust.eval;
2+
3+
import dev.braintrust.trace.BrainstoreTrace;
4+
import java.util.List;
5+
6+
/**
7+
* A scorer that receives access to the full distributed trace of the task that was evaluated.
8+
*
9+
* <p>Implement this interface when your scorer needs to examine intermediate LLM calls, tool
10+
* invocations, or other spans produced during task execution — not just the final {@code
11+
* TaskResult}.
12+
*
13+
* @param <INPUT> type of the input data
14+
* @param <OUTPUT> type of the output data
15+
*/
16+
public interface TracedScorer<INPUT, OUTPUT> extends Scorer<INPUT, OUTPUT> {
17+
18+
/**
19+
* Scores the task result using the distributed trace for additional context. Called instead of
20+
* {@link Scorer#score(TaskResult)} when a {@link BrainstoreTrace} is available.
21+
*
22+
* @param taskResult the task output and originating dataset case
23+
* @param trace lazy access to the distributed trace spans for this eval case
24+
* @return one or more scores, each with a value between 0 and 1 inclusive
25+
*/
26+
List<Score> score(TaskResult<INPUT, OUTPUT> taskResult, BrainstoreTrace trace);
27+
28+
/**
29+
* {@inheritDoc}
30+
*
31+
* <p>When used inside an {@link Eval}, this overload is never called — {@link
32+
* #score(TaskResult, BrainstoreTrace)} is dispatched instead. This default implementation
33+
* throws {@link UnsupportedOperationException} to surface any accidental direct calls.
34+
*/
35+
@Override
36+
default List<Score> score(TaskResult<INPUT, OUTPUT> taskResult) {
37+
throw new RuntimeException(
38+
"traced scorer score method directly called. This is likely an accident. If you"
39+
+ " wish to support this, your implementation must override this method.");
40+
}
41+
}

0 commit comments

Comments
 (0)