|
5 | 5 | import com.anthropic.core.http.HttpRequest; |
6 | 6 | import com.anthropic.core.http.HttpRequestBody; |
7 | 7 | import com.anthropic.core.http.HttpResponse; |
8 | | -import com.anthropic.helpers.MessageAccumulator; |
9 | | -import com.anthropic.models.messages.RawMessageStreamEvent; |
10 | 8 | import dev.braintrust.bootstrap.BraintrustBridge; |
11 | 9 | import dev.braintrust.instrumentation.InstrumentationSemConv; |
12 | | -import dev.braintrust.json.BraintrustJsonMapper; |
13 | 10 | import io.opentelemetry.api.OpenTelemetry; |
14 | 11 | import io.opentelemetry.api.trace.Span; |
15 | 12 | import io.opentelemetry.api.trace.SpanContext; |
16 | 13 | import io.opentelemetry.api.trace.TraceFlags; |
17 | 14 | import io.opentelemetry.api.trace.TraceState; |
18 | 15 | import io.opentelemetry.api.trace.Tracer; |
19 | 16 | import io.opentelemetry.context.Context; |
20 | | -import java.io.BufferedReader; |
21 | | -import java.io.ByteArrayInputStream; |
22 | 17 | import java.io.ByteArrayOutputStream; |
23 | 18 | import java.io.InputStream; |
24 | | -import java.io.InputStreamReader; |
25 | 19 | import java.io.OutputStream; |
26 | 20 | import java.nio.charset.StandardCharsets; |
| 21 | +import java.util.HashMap; |
| 22 | +import java.util.List; |
| 23 | +import java.util.Map; |
27 | 24 | import java.util.concurrent.CompletableFuture; |
28 | 25 | import java.util.concurrent.atomic.AtomicBoolean; |
29 | 26 | import java.util.concurrent.atomic.AtomicLong; |
@@ -125,7 +122,9 @@ public void close() { |
125 | 122 | bufferedRequest.baseUrl() != null ? bufferedRequest.baseUrl() : "", |
126 | 123 | bufferedRequest.pathSegments(), |
127 | 124 | bufferedRequest.method().name(), |
128 | | - inputJson); |
| 125 | + inputJson, |
| 126 | + null, |
| 127 | + headersAsMap(bufferedRequest.headers())); |
129 | 128 |
|
130 | 129 | var response = underlying.execute(bufferedRequest, requestOptions); |
131 | 130 | return new TeeingStreamHttpResponse(response, span, tracer); |
@@ -153,7 +152,9 @@ public void close() { |
153 | 152 | bufferedRequest.baseUrl() != null ? bufferedRequest.baseUrl() : "", |
154 | 153 | bufferedRequest.pathSegments(), |
155 | 154 | bufferedRequest.method().name(), |
156 | | - inputJson); |
| 155 | + inputJson, |
| 156 | + null, |
| 157 | + headersAsMap(bufferedRequest.headers())); |
157 | 158 | return underlying |
158 | 159 | .executeAsync(bufferedRequest, requestOptions) |
159 | 160 | .thenApply( |
@@ -264,9 +265,34 @@ private void onStreamClosed() { |
264 | 265 | synchronized (teeBuffer) { |
265 | 266 | bytes = teeBuffer.toByteArray(); |
266 | 267 | } |
| 268 | + |
| 269 | + // Recorded before tagging: the anthropic sdk raises above this layer, so the |
| 270 | + // error status is ours alone to set, and losing it to a body-parsing problem is |
| 271 | + // worse than losing the parsed output. |
| 272 | + // Anything outside 2xx, not just 4xx/5xx: both vendor SDKs treat success as |
| 273 | + // exactly 200..299, so a final 3xx that the http client did not follow (a 304, or |
| 274 | + // a redirect with no usable Location) is raised to the caller as an |
| 275 | + // UnexpectedStatusCodeException and must mark the span failed too. |
| 276 | + int statusCode = delegate.statusCode(); |
| 277 | + if (statusCode < 200 || statusCode >= 300) { |
| 278 | + InstrumentationSemConv.tagLLMSpanHttpError( |
| 279 | + span, statusCode, new String(bytes, StandardCharsets.UTF_8)); |
| 280 | + } |
| 281 | + |
| 282 | + // Wire-format bookkeeping lives in ResponseReassembler; this hands semconv |
| 283 | + // everything the response carried in one flat call. A null body (empty or |
| 284 | + // unparseable response) still tags the headers. |
267 | 285 | // tagLLMSpanResponse also emits child spans for any server-side tool calls (web |
268 | 286 | // search, etc.) nested under the LLM span while it is still live. |
269 | | - tagSpanFromBuffer(tracer, span, bytes, timeToFirstTokenNanos.get()); |
| 287 | + var reassembled = |
| 288 | + ResponseReassembler.reassemble(bytes, timeToFirstTokenNanos.get()); |
| 289 | + InstrumentationSemConv.tagLLMSpanResponse( |
| 290 | + tracer, |
| 291 | + span, |
| 292 | + InstrumentationSemConv.PROVIDER_NAME_ANTHROPIC, |
| 293 | + reassembled.body(), |
| 294 | + reassembled.timeToFirstTokenNanos(), |
| 295 | + headersAsMap(delegate.headers())); |
270 | 296 | } finally { |
271 | 297 | span.end(); |
272 | 298 | } |
@@ -360,89 +386,25 @@ private void notifyClosed() { |
360 | 386 | // Span tagging from buffered bytes |
361 | 387 | // ------------------------------------------------------------------------- |
362 | 388 |
|
363 | | - private static void tagSpanFromBuffer( |
364 | | - Tracer tracer, Span span, byte[] bytes, Long timeToFirstTokenNanos) { |
365 | | - if (bytes.length == 0) return; |
366 | | - try { |
367 | | - String firstLine = firstNonEmptyLine(bytes); |
368 | | - // Anthropic SSE starts with "event: message_start\ndata: ..." so we detect |
369 | | - // either prefix. OpenAI SSE starts directly with "data:". |
370 | | - boolean isSse = |
371 | | - firstLine != null |
372 | | - && (firstLine.startsWith("data:") || firstLine.startsWith("event:")); |
373 | | - if (isSse) { |
374 | | - tagSpanFromSseBytes(tracer, span, bytes, timeToFirstTokenNanos); |
375 | | - } else { |
376 | | - // Non-streaming: plain Message JSON — pass it whole, no time_to_first_token |
377 | | - String responseJson = new String(bytes, StandardCharsets.UTF_8); |
378 | | - InstrumentationSemConv.tagLLMSpanResponse( |
379 | | - tracer, |
380 | | - span, |
381 | | - InstrumentationSemConv.PROVIDER_NAME_ANTHROPIC, |
382 | | - responseJson, |
383 | | - null); |
384 | | - } |
385 | | - } catch (Exception e) { |
386 | | - log.error("Could not tag span from Anthropic response buffer", e); |
387 | | - } |
388 | | - } |
389 | | - |
390 | | - private static String firstNonEmptyLine(byte[] bytes) { |
391 | | - int start = 0; |
392 | | - for (int i = 0; i <= bytes.length; i++) { |
393 | | - if (i == bytes.length || bytes[i] == '\n') { |
394 | | - String line = new String(bytes, start, i - start, StandardCharsets.UTF_8).strip(); |
395 | | - if (!line.isEmpty()) return line; |
396 | | - start = i + 1; |
397 | | - } |
398 | | - } |
399 | | - return null; |
400 | | - } |
401 | | - |
402 | 389 | /** |
403 | | - * Anthropic SSE wire format has named events: |
404 | | - * |
405 | | - * <pre> |
406 | | - * event: message_start |
407 | | - * data: {"type":"message_start","message":{...}} |
408 | | - * |
409 | | - * event: content_block_delta |
410 | | - * data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}} |
411 | | - * </pre> |
412 | | - * |
413 | | - * We only need the {@code data:} lines — the event name is redundant with the {@code type} |
414 | | - * field inside the JSON. Feed each data payload to {@link MessageAccumulator} and serialize the |
415 | | - * assembled {@link com.anthropic.models.messages.Message} for the span. |
| 390 | + * Adapts the anthropic sdk's {@code Headers} to the vendor-neutral shape {@link |
| 391 | + * InstrumentationSemConv} consumes. Returns an empty map on failure so a header-shape change |
| 392 | + * can never take down the tagging that follows it. |
416 | 393 | */ |
417 | | - private static void tagSpanFromSseBytes( |
418 | | - Tracer tracer, Span span, byte[] sseBytes, Long timeToFirstTokenNanos) { |
| 394 | + private static Map<String, List<String>> headersAsMap( |
| 395 | + @Nullable com.anthropic.core.http.Headers headers) { |
| 396 | + if (headers == null) { |
| 397 | + return Map.of(); |
| 398 | + } |
419 | 399 | try { |
420 | | - var mapper = BraintrustJsonMapper.get(); |
421 | | - var reader = |
422 | | - new BufferedReader( |
423 | | - new InputStreamReader( |
424 | | - new ByteArrayInputStream(sseBytes), StandardCharsets.UTF_8)); |
425 | | - var accumulator = MessageAccumulator.create(); |
426 | | - String line; |
427 | | - while ((line = reader.readLine()) != null) { |
428 | | - if (!line.startsWith("data:")) continue; |
429 | | - String data = line.substring("data:".length()).strip(); |
430 | | - if (data.isEmpty()) continue; |
431 | | - try { |
432 | | - accumulator.accumulate(mapper.readValue(data, RawMessageStreamEvent.class)); |
433 | | - } catch (Exception ignored) { |
434 | | - // skip unrecognized event types (e.g. ping) |
435 | | - } |
| 400 | + var map = new HashMap<String, List<String>>(); |
| 401 | + for (String name : headers.names()) { |
| 402 | + map.put(name, headers.values(name)); |
436 | 403 | } |
437 | | - String assembledMessageJson = BraintrustJsonMapper.toJson(accumulator.message()); |
438 | | - InstrumentationSemConv.tagLLMSpanResponse( |
439 | | - tracer, |
440 | | - span, |
441 | | - InstrumentationSemConv.PROVIDER_NAME_ANTHROPIC, |
442 | | - assembledMessageJson, |
443 | | - timeToFirstTokenNanos); |
| 404 | + return map; |
444 | 405 | } catch (Exception e) { |
445 | | - log.error("Could not parse Anthropic SSE buffer to tag streaming span output", e); |
| 406 | + log.debug("could not read headers", e); |
| 407 | + return Map.of(); |
446 | 408 | } |
447 | 409 | } |
448 | 410 | } |
0 commit comments