attributes() {
+ return attributes;
+ }
+ }
+}
diff --git a/java/traceai-spring-ai/src/main/java/ai/traceai/spring/StreamOutputCapture.java b/java/traceai-spring-ai/src/main/java/ai/traceai/spring/StreamOutputCapture.java
new file mode 100644
index 00000000..0f1fe2dc
--- /dev/null
+++ b/java/traceai-spring-ai/src/main/java/ai/traceai/spring/StreamOutputCapture.java
@@ -0,0 +1,82 @@
+package ai.traceai.spring;
+
+import ai.traceai.FITracer;
+import ai.traceai.TraceConfig;
+import io.opentelemetry.api.trace.Span;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+
+import java.util.Collections;
+
+/**
+ * Bounded per-subscription output capture for streamed chat responses.
+ *
+ * Allocates no text buffer when both output switches ({@code hideOutputs} and {@code hideOutputMessages})
+ * hide the content. Otherwise retains at most a 32,000 character prefix plus an overflow flag; the delivered
+ * stream elements are never modified or truncated. Token usage is never aggregated from chunks.
+ */
+final class StreamOutputCapture {
+
+ /** Matches the OpenTelemetry attribute budget used by {@link FITracer}. */
+ static final int MAX_CHARS = 32_000;
+ static final String TRUNCATION_SUFFIX = "...[truncated]";
+
+ private final FITracer tracer;
+ private final StringBuilder buffer;
+ private boolean overflow;
+
+ StreamOutputCapture(FITracer tracer) {
+ this.tracer = tracer;
+ TraceConfig config = tracer.getConfig();
+ boolean hideAll = config.isHideOutputs() && config.isHideOutputMessages();
+ this.buffer = hideAll ? null : new StringBuilder();
+ }
+
+ boolean isCapturing() {
+ return buffer != null;
+ }
+
+ boolean hasOverflow() {
+ return overflow;
+ }
+
+ void accept(ChatResponse response) {
+ if (buffer == null) {
+ return;
+ }
+ Generation generation = response.getResult();
+ if (generation == null) {
+ return;
+ }
+ AssistantMessage output = generation.getOutput();
+ if (output == null) {
+ return;
+ }
+ String text = output.getText();
+ if (text == null || text.isEmpty()) {
+ return;
+ }
+ int remaining = MAX_CHARS - buffer.length();
+ if (text.length() <= remaining) {
+ buffer.append(text);
+ } else {
+ if (remaining > 0) {
+ buffer.append(text, 0, remaining);
+ }
+ overflow = true;
+ }
+ }
+
+ /** Applies the captured output to the span; privacy switches are enforced by {@link FITracer}. */
+ void complete(Span span) {
+ if (buffer == null) {
+ return;
+ }
+ String text = overflow
+ ? buffer.substring(0, MAX_CHARS - TRUNCATION_SUFFIX.length()) + TRUNCATION_SUFFIX
+ : buffer.toString();
+ tracer.setOutputValue(span, text);
+ tracer.setOutputMessages(span, Collections.singletonList(FITracer.message("assistant", text)));
+ }
+}
diff --git a/java/traceai-spring-ai/src/main/java/ai/traceai/spring/TracedChatModel.java b/java/traceai-spring-ai/src/main/java/ai/traceai/spring/TracedChatModel.java
index 8df591a1..1e2acdfc 100644
--- a/java/traceai-spring-ai/src/main/java/ai/traceai/spring/TracedChatModel.java
+++ b/java/traceai-spring-ai/src/main/java/ai/traceai/spring/TracedChatModel.java
@@ -1,35 +1,55 @@
package ai.traceai.spring;
-import ai.traceai.*;
+import ai.traceai.FISpanKind;
+import ai.traceai.FITracer;
+import ai.traceai.SemanticConventions;
+import ai.traceai.TraceAI;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.metadata.ChatResponseMetadata;
+import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
-import org.springframework.ai.chat.messages.Message;
+import reactor.core.CoreSubscriber;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
/**
- * Traced wrapper for Spring AI ChatModel.
- * Implements the ChatModel interface to provide automatic tracing.
+ * Traced wrapper for a Spring AI 1.1.x {@link ChatModel}.
+ *
+ * The wrapper passes the exact {@link Prompt} to the delegate and returns the delegate's exact
+ * {@link ChatResponse} (or stream elements). It never retries, substitutes or caches responses.
*
* Usage:
*
- * ChatModel model = new OpenAiChatModel(api);
- * ChatModel tracedModel = new TracedChatModel(model, "openai");
+ * ChatModel model = OllamaChatModel.builder()...build();
+ * ChatModel tracedModel = new TracedChatModel(model, tracer, "ollama");
*
* ChatResponse response = tracedModel.call(new Prompt("Hello!"));
*
+ *
+ * Streaming: {@link #stream(Prompt)} starts no span and does not call the delegate until a subscriber
+ * subscribes. Each subscription owns one span that ends exactly once on complete, error or cancel. Streamed
+ * token usage is not aggregated. To bind a parent span and TraceAI session/user attributes to a stream that
+ * is subscribed on another thread, use {@link SpringAITracingContext#withContext}.
*/
public class TracedChatModel implements ChatModel {
+ static final String SPAN_NAME_CALL = "Spring AI Chat";
+ static final String SPAN_NAME_STREAM = "Spring AI Chat (Stream)";
+
private final ChatModel delegate;
private final FITracer tracer;
private final String provider;
@@ -42,8 +62,8 @@ public class TracedChatModel implements ChatModel {
* @param provider the provider name (e.g., "openai", "anthropic")
*/
public TracedChatModel(ChatModel delegate, FITracer tracer, String provider) {
- this.delegate = delegate;
- this.tracer = tracer;
+ this.delegate = Objects.requireNonNull(delegate, "delegate ChatModel must not be null");
+ this.tracer = Objects.requireNonNull(tracer, "FITracer must not be null");
this.provider = provider;
}
@@ -52,6 +72,7 @@ public TracedChatModel(ChatModel delegate, FITracer tracer, String provider) {
*
* @param delegate the underlying model to wrap
* @param provider the provider name
+ * @throws IllegalStateException if {@link TraceAI#init} has not been called
*/
public TracedChatModel(ChatModel delegate, String provider) {
this(delegate, TraceAI.getTracer(), provider);
@@ -59,174 +80,201 @@ public TracedChatModel(ChatModel delegate, String provider) {
@Override
public ChatResponse call(Prompt prompt) {
- Span span = tracer.startSpan("Spring AI Chat", FISpanKind.LLM);
-
- try (Scope scope = span.makeCurrent()) {
- // Set system attributes
- span.setAttribute(SemanticConventions.LLM_SYSTEM, "spring-ai");
- span.setAttribute(SemanticConventions.LLM_PROVIDER, provider);
-
- // Capture input messages and input.value
- if (prompt != null && prompt.getInstructions() != null) {
- List messages = prompt.getInstructions();
- List