diff --git a/spring-ai-core/pom.xml b/spring-ai-core/pom.xml
index 9b600c4b46..10bfcbb4c4 100644
--- a/spring-ai-core/pom.xml
+++ b/spring-ai-core/pom.xml
@@ -84,6 +84,12 @@
micrometer-core
+
+ io.micrometer
+ micrometer-tracing-bridge-otel
+ true
+
+
com.knuddels
jtokkit
diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java
index 6d70a80bb6..aadc292de0 100644
--- a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java
+++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java
@@ -17,10 +17,6 @@
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationFilter;
-import org.springframework.util.CollectionUtils;
-import org.springframework.util.StringUtils;
-
-import java.util.StringJoiner;
/**
* An {@link ObservationFilter} to include the chat completion content in the observation.
@@ -36,25 +32,11 @@ public Observation.Context map(Observation.Context context) {
return context;
}
- if (chatModelObservationContext.getResponse() == null
- || chatModelObservationContext.getResponse().getResults() == null
- || CollectionUtils.isEmpty(chatModelObservationContext.getResponse().getResults())) {
- return chatModelObservationContext;
- }
-
- StringJoiner completionChoicesJoiner = new StringJoiner(", ", "[", "]");
- chatModelObservationContext.getResponse()
- .getResults()
- .stream()
- .filter(generation -> generation.getOutput() != null
- && StringUtils.hasText(generation.getOutput().getContent()))
- .forEach(generation -> completionChoicesJoiner.add("\"" + generation.getOutput().getContent() + "\""));
+ var completions = ChatModelObservationContentProcessor.completion(chatModelObservationContext);
- if (StringUtils.hasText(chatModelObservationContext.getResponse().getResult().getOutput().getContent())) {
- chatModelObservationContext
- .addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.COMPLETION
- .withValue(completionChoicesJoiner.toString()));
- }
+ chatModelObservationContext
+ .addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.COMPLETION
+ .withValue(ChatModelObservationContentProcessor.concatenateStrings(completions)));
return chatModelObservationContext;
}
diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java
new file mode 100644
index 0000000000..f6c2efe80e
--- /dev/null
+++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.ai.chat.observation;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationHandler;
+import io.micrometer.tracing.handler.TracingObservationHandler;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
+import org.springframework.ai.observation.conventions.AiObservationAttributes;
+import org.springframework.ai.observation.conventions.AiObservationEventNames;
+
+/**
+ * Handler for including the chat completion content in the observation as a span event.
+ *
+ * @author Thomas Vitale
+ * @since 1.0.0
+ */
+public class ChatModelCompletionObservationHandler implements ObservationHandler {
+
+ @Override
+ public void onStop(ChatModelObservationContext context) {
+ TracingObservationHandler.TracingContext tracingContext = context
+ .get(TracingObservationHandler.TracingContext.class);
+ Span otelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
+
+ if (otelSpan != null) {
+ otelSpan.addEvent(AiObservationEventNames.CONTENT_COMPLETION.value(),
+ Attributes.of(AttributeKey.stringArrayKey(AiObservationAttributes.COMPLETION.value()),
+ ChatModelObservationContentProcessor.completion(context)));
+ }
+ }
+
+ @Override
+ public boolean supportsContext(Observation.Context context) {
+ return context instanceof ChatModelObservationContext;
+ }
+
+}
diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java
new file mode 100644
index 0000000000..b77aebf9ad
--- /dev/null
+++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.ai.chat.observation;
+
+import io.micrometer.tracing.handler.TracingObservationHandler;
+import io.opentelemetry.api.trace.Span;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.model.Content;
+import org.springframework.lang.Nullable;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.StringJoiner;
+
+/**
+ * Utilities to process the prompt and completion content in observations for chat models.
+ *
+ * @author Thomas Vitale
+ */
+public final class ChatModelObservationContentProcessor {
+
+ private static final Logger logger = LoggerFactory.getLogger(ChatModelObservationContentProcessor.class);
+
+ public static List prompt(ChatModelObservationContext context) {
+ if (CollectionUtils.isEmpty(context.getRequest().getInstructions())) {
+ return List.of();
+ }
+
+ return context.getRequest().getInstructions().stream().map(Content::getContent).toList();
+ }
+
+ public static List completion(ChatModelObservationContext context) {
+ if (context.getResponse() == null || context.getResponse().getResults() == null
+ || CollectionUtils.isEmpty(context.getResponse().getResults())) {
+ return List.of();
+ }
+
+ if (!StringUtils.hasText(context.getResponse().getResult().getOutput().getContent())) {
+ return List.of();
+ }
+
+ return context.getResponse()
+ .getResults()
+ .stream()
+ .filter(generation -> generation.getOutput() != null
+ && StringUtils.hasText(generation.getOutput().getContent()))
+ .map(generation -> generation.getOutput().getContent())
+ .toList();
+ }
+
+ public static String concatenateStrings(List strings) {
+ var promptMessagesJoiner = new StringJoiner(", ", "[", "]");
+ strings.forEach(string -> promptMessagesJoiner.add("\"" + string + "\""));
+ return promptMessagesJoiner.toString();
+ }
+
+ @Nullable
+ public static Span extractOtelSpan(@Nullable TracingObservationHandler.TracingContext tracingContext) {
+ if (tracingContext == null) {
+ return null;
+ }
+
+ io.micrometer.tracing.Span micrometerSpan = tracingContext.getSpan();
+ try {
+ Method toOtelMethod = tracingContext.getSpan()
+ .getClass()
+ .getDeclaredMethod("toOtel", io.micrometer.tracing.Span.class);
+ toOtelMethod.setAccessible(true);
+ Object otelSpanObject = toOtelMethod.invoke(null, micrometerSpan);
+ if (otelSpanObject instanceof Span otelSpan) {
+ return otelSpan;
+ }
+ }
+ catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException ex) {
+ logger.warn("It wasn't possible to extract the OpenTelemetry Span object from Micrometer", ex);
+ return null;
+ }
+
+ return null;
+ }
+
+}
diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java
index 758ff0c7dc..785fddf92c 100644
--- a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java
+++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java
@@ -17,9 +17,6 @@
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationFilter;
-import org.springframework.util.CollectionUtils;
-
-import java.util.StringJoiner;
/**
* An {@link ObservationFilter} to include the chat prompt content in the observation.
@@ -35,18 +32,11 @@ public Observation.Context map(Observation.Context context) {
return context;
}
- if (CollectionUtils.isEmpty(chatModelObservationContext.getRequest().getInstructions())) {
- return chatModelObservationContext;
- }
-
- StringJoiner promptMessagesJoiner = new StringJoiner(", ", "[", "]");
- chatModelObservationContext.getRequest()
- .getInstructions()
- .forEach(message -> promptMessagesJoiner.add("\"" + message.getContent() + "\""));
+ var prompts = ChatModelObservationContentProcessor.prompt(chatModelObservationContext);
chatModelObservationContext
.addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT
- .withValue(promptMessagesJoiner.toString()));
+ .withValue(ChatModelObservationContentProcessor.concatenateStrings(prompts)));
return chatModelObservationContext;
}
diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java
new file mode 100644
index 0000000000..61866a4730
--- /dev/null
+++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.ai.chat.observation;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationHandler;
+import io.micrometer.tracing.handler.TracingObservationHandler;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
+import org.springframework.ai.observation.conventions.AiObservationAttributes;
+import org.springframework.ai.observation.conventions.AiObservationEventNames;
+
+/**
+ * Handler for including the chat prompt content in the observation as a span event.
+ *
+ * @author Thomas Vitale
+ * @since 1.0.0
+ */
+public class ChatModelPromptContentObservationHandler implements ObservationHandler {
+
+ @Override
+ public void onStop(ChatModelObservationContext context) {
+ TracingObservationHandler.TracingContext tracingContext = context
+ .get(TracingObservationHandler.TracingContext.class);
+ Span otelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
+
+ if (otelSpan != null) {
+ otelSpan.addEvent(AiObservationEventNames.CONTENT_PROMPT.value(),
+ Attributes.of(AttributeKey.stringArrayKey(AiObservationAttributes.PROMPT.value()),
+ ChatModelObservationContentProcessor.prompt(context)));
+ }
+
+ }
+
+ @Override
+ public boolean supportsContext(Observation.Context context) {
+ return context instanceof ChatModelObservationContext;
+ }
+
+}
diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java
new file mode 100644
index 0000000000..261493c134
--- /dev/null
+++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.ai.chat.observation;
+
+import io.micrometer.tracing.handler.TracingObservationHandler;
+import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
+import io.micrometer.tracing.otel.bridge.OtelTracer;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.sdk.trace.ReadableSpan;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.ChatOptionsBuilder;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.observation.conventions.AiObservationAttributes;
+import org.springframework.ai.observation.conventions.AiObservationEventNames;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link ChatModelCompletionObservationHandler}.
+ *
+ * @author Thomas Vitale
+ */
+class ChatModelCompletionObservationHandlerTests {
+
+ @Test
+ void whenCompletionWithTextThenSpanEvent() {
+ var observationContext = ChatModelObservationContext.builder()
+ .prompt(new Prompt("supercalifragilisticexpialidocious"))
+ .provider("mary-poppins")
+ .requestOptions(ChatOptionsBuilder.builder().withModel("spoonful-of-sugar").build())
+ .build();
+ observationContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("say please")),
+ new Generation(new AssistantMessage("seriously, say please")))));
+ var sdkTracer = SdkTracerProvider.builder().build().get("test");
+ var otelTracer = new OtelTracer(sdkTracer, new OtelCurrentTraceContext(), null);
+ var span = otelTracer.nextSpan();
+ var tracingContext = new TracingObservationHandler.TracingContext();
+ tracingContext.setSpan(span);
+ observationContext.put(TracingObservationHandler.TracingContext.class, tracingContext);
+
+ new ChatModelCompletionObservationHandler().onStop(observationContext);
+
+ var otelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
+ assertThat(otelSpan).isNotNull();
+ var spanData = ((ReadableSpan) otelSpan).toSpanData();
+ assertThat(spanData.getEvents().size()).isEqualTo(1);
+ assertThat(spanData.getEvents().get(0).getName()).isEqualTo(AiObservationEventNames.CONTENT_COMPLETION.value());
+ assertThat(spanData.getEvents()
+ .get(0)
+ .getAttributes()
+ .get(AttributeKey.stringArrayKey(AiObservationAttributes.COMPLETION.value())))
+ .containsOnly("say please", "seriously, say please");
+ }
+
+}
diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessorTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessorTests.java
new file mode 100644
index 0000000000..229a4ae3a1
--- /dev/null
+++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessorTests.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.ai.chat.observation;
+
+import io.micrometer.tracing.Span;
+import io.micrometer.tracing.TraceContext;
+import io.micrometer.tracing.handler.TracingObservationHandler;
+import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
+import io.micrometer.tracing.otel.bridge.OtelTracer;
+import io.opentelemetry.api.OpenTelemetry;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link ChatModelObservationContentProcessor}.
+ *
+ * @author Thomas Vitale
+ */
+class ChatModelObservationContentProcessorTests {
+
+ @Test
+ void extractOtelSpanWhenTracingContextIsNull() {
+ var actualOtelSpan = ChatModelObservationContentProcessor.extractOtelSpan(null);
+ assertThat(actualOtelSpan).isNull();
+ }
+
+ @Test
+ void extractOtelSpanWhenMethodDoesNotExist() {
+ var tracingContext = new TracingObservationHandler.TracingContext();
+ tracingContext.setSpan(Span.NOOP);
+ var actualOtelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
+ assertThat(actualOtelSpan).isNull();
+ }
+
+ @Test
+ void extractOtelSpanWhenSpanIsNotOpenTelemetry() {
+ var tracingContext = new TracingObservationHandler.TracingContext();
+ tracingContext.setSpan(new DemoOtherSpan());
+ var actualOtelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
+ assertThat(actualOtelSpan).isNull();
+ }
+
+ @Test
+ void extractOtelSpanWhenSpanIsOpenTelemetry() {
+ var tracingContext = new TracingObservationHandler.TracingContext();
+ var otelTracer = new OtelTracer(OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null);
+ tracingContext.setSpan(otelTracer.nextSpan());
+ var actualOtelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
+ assertThat(actualOtelSpan).isNotNull();
+ assertThat(actualOtelSpan).isInstanceOf(io.opentelemetry.api.trace.Span.class);
+ }
+
+ static class DemoOtherSpan implements Span {
+
+ private static Span toOtel(Span span) {
+ return Span.NOOP;
+ }
+
+ @Override
+ public boolean isNoop() {
+ return false;
+ }
+
+ @Override
+ public TraceContext context() {
+ return null;
+ }
+
+ @Override
+ public Span start() {
+ return null;
+ }
+
+ @Override
+ public Span name(String s) {
+ return null;
+ }
+
+ @Override
+ public Span event(String s) {
+ return null;
+ }
+
+ @Override
+ public Span event(String s, long l, TimeUnit timeUnit) {
+ return null;
+ }
+
+ @Override
+ public Span tag(String s, String s1) {
+ return null;
+ }
+
+ @Override
+ public Span error(Throwable throwable) {
+ return null;
+ }
+
+ @Override
+ public void end() {
+
+ }
+
+ @Override
+ public void end(long l, TimeUnit timeUnit) {
+
+ }
+
+ @Override
+ public void abandon() {
+
+ }
+
+ @Override
+ public Span remoteServiceName(String s) {
+ return null;
+ }
+
+ @Override
+ public Span remoteIpAndPort(String s, int i) {
+ return null;
+ }
+
+ }
+
+}
diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java
new file mode 100644
index 0000000000..681327e8e0
--- /dev/null
+++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.ai.chat.observation;
+
+import io.micrometer.tracing.handler.TracingObservationHandler;
+import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
+import io.micrometer.tracing.otel.bridge.OtelTracer;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.sdk.trace.ReadableSpan;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.prompt.ChatOptionsBuilder;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.observation.conventions.AiObservationAttributes;
+import org.springframework.ai.observation.conventions.AiObservationEventNames;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link ChatModelPromptContentObservationHandler}.
+ *
+ * @author Thomas Vitale
+ */
+class ChatModelPromptContentObservationHandlerTests {
+
+ @Test
+ void whenPromptWithTextThenSpanEvent() {
+ var observationContext = ChatModelObservationContext.builder()
+ .prompt(new Prompt("supercalifragilisticexpialidocious"))
+ .provider("mary-poppins")
+ .requestOptions(ChatOptionsBuilder.builder().withModel("spoonful-of-sugar").build())
+ .build();
+ var sdkTracer = SdkTracerProvider.builder().build().get("test");
+ var otelTracer = new OtelTracer(sdkTracer, new OtelCurrentTraceContext(), null);
+ var span = otelTracer.nextSpan();
+ var tracingContext = new TracingObservationHandler.TracingContext();
+ tracingContext.setSpan(span);
+ observationContext.put(TracingObservationHandler.TracingContext.class, tracingContext);
+
+ new ChatModelPromptContentObservationHandler().onStop(observationContext);
+
+ var otelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
+ assertThat(otelSpan).isNotNull();
+ var spanData = ((ReadableSpan) otelSpan).toSpanData();
+ assertThat(spanData.getEvents().size()).isEqualTo(1);
+ assertThat(spanData.getEvents().get(0).getName()).isEqualTo(AiObservationEventNames.CONTENT_PROMPT.value());
+ assertThat(spanData.getEvents()
+ .get(0)
+ .getAttributes()
+ .get(AttributeKey.stringArrayKey(AiObservationAttributes.PROMPT.value())))
+ .containsOnly("supercalifragilisticexpialidocious");
+ }
+
+}
diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml
index 7a21efbfd7..dface18993 100644
--- a/spring-ai-spring-boot-autoconfigure/pom.xml
+++ b/spring-ai-spring-boot-autoconfigure/pom.xml
@@ -36,6 +36,12 @@
spring-boot-starter
+
+ io.micrometer
+ micrometer-tracing-bridge-otel
+ true
+
+
org.springframework.ai
spring-ai-openai
diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java
index aabc1b6b0b..4b51538bde 100644
--- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java
+++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java
@@ -16,20 +16,17 @@
package org.springframework.ai.autoconfigure.chat.observation;
import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.tracing.otel.bridge.OtelTracer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ChatModel;
-import org.springframework.ai.chat.observation.ChatModelCompletionObservationFilter;
-import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler;
-import org.springframework.ai.chat.observation.ChatModelPromptContentObservationFilter;
+import org.springframework.ai.chat.observation.*;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.*;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
/**
* Auto-configuration for Spring AI chat model observations.
@@ -52,24 +49,70 @@ ChatModelMeterObservationHandler chatModelMeterObservationHandler(ObjectProvider
return new ChatModelMeterObservationHandler(meterRegistry.getObject());
}
- @Bean
- @ConditionalOnMissingBean
- @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-prompt",
- havingValue = "true")
- ChatModelPromptContentObservationFilter chatModelPromptObservationFilter() {
+ /**
+ * The chat content is typically too big to be included in an observation as span
+ * attributes. That's why the preferred way to store it is as span events, which are
+ * supported by OpenTelemetry but not yet surfaced through the Micrometer APIs. This
+ * primary/fallback configuration is a temporary solution until
+ * https://github.com/micrometer-metrics/micrometer/issues/5238 is delivered.
+ */
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnClass(OtelTracer.class)
+ @ConditionalOnBean(OtelTracer.class)
+ static class PrimaryChatContentObservationConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-prompt",
+ havingValue = "true")
+ ChatModelPromptContentObservationHandler chatModelPromptContentObservationHandler() {
+ logPromptContentWarning();
+ return new ChatModelPromptContentObservationHandler();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-completion",
+ havingValue = "true")
+ ChatModelCompletionObservationHandler chatModelCompletionObservationHandler() {
+ logCompletionWarning();
+ return new ChatModelCompletionObservationHandler();
+ }
+
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnMissingClass("io.micrometer.tracing.otel.bridge.OtelTracer")
+ static class FallbackChatContentObservationConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-prompt",
+ havingValue = "true")
+ ChatModelPromptContentObservationFilter chatModelPromptObservationFilter() {
+ logPromptContentWarning();
+ return new ChatModelPromptContentObservationFilter();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-completion",
+ havingValue = "true")
+ ChatModelCompletionObservationFilter chatModelCompletionObservationFilter() {
+ logCompletionWarning();
+ return new ChatModelCompletionObservationFilter();
+ }
+
+ }
+
+ private static void logPromptContentWarning() {
logger.warn(
"You have enabled the inclusion of the prompt content in the observations, with the risk of exposing sensitive or private information. Please, be careful!");
- return new ChatModelPromptContentObservationFilter();
}
- @Bean
- @ConditionalOnMissingBean
- @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-completion",
- havingValue = "true")
- ChatModelCompletionObservationFilter chatModelCompletionObservationFilter() {
+ private static void logCompletionWarning() {
logger.warn(
"You have enabled the inclusion of the completion content in the observations, with the risk of exposing sensitive or private information. Please, be careful!");
- return new ChatModelCompletionObservationFilter();
}
}
diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java
index 98dcb33ee0..7e4f01a694 100644
--- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java
+++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java
@@ -16,10 +16,11 @@
package org.springframework.ai.autoconfigure.chat.observation;
import io.micrometer.core.instrument.composite.CompositeMeterRegistry;
+import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
+import io.micrometer.tracing.otel.bridge.OtelTracer;
+import io.opentelemetry.api.OpenTelemetry;
import org.junit.jupiter.api.Test;
-import org.springframework.ai.chat.observation.ChatModelCompletionObservationFilter;
-import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler;
-import org.springframework.ai.chat.observation.ChatModelPromptContentObservationFilter;
+import org.springframework.ai.chat.observation.*;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -57,9 +58,26 @@ void promptFilterDefault() {
}
@Test
- void promptFilterEnabled() {
+ void promptHandlerDefault() {
+ contextRunner.run(context -> {
+ assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class);
+ });
+ }
+
+ @Test
+ void promptHandlerEnabled() {
+ contextRunner
+ .withBean(OtelTracer.class, OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null)
+ .withPropertyValues("spring.ai.chat.observations.include-prompt=true")
+ .run(context -> {
+ assertThat(context).hasSingleBean(ChatModelPromptContentObservationHandler.class);
+ });
+ }
+
+ @Test
+ void promptHandlerDisabled() {
contextRunner.withPropertyValues("spring.ai.chat.observations.include-prompt=true").run(context -> {
- assertThat(context).hasSingleBean(ChatModelPromptContentObservationFilter.class);
+ assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class);
});
}
@@ -71,9 +89,26 @@ void completionFilterDefault() {
}
@Test
- void completionFilterEnabled() {
+ void completionHandlerDefault() {
+ contextRunner.run(context -> {
+ assertThat(context).doesNotHaveBean(ChatModelCompletionObservationHandler.class);
+ });
+ }
+
+ @Test
+ void completionHandlerEnabled() {
+ contextRunner
+ .withBean(OtelTracer.class, OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null)
+ .withPropertyValues("spring.ai.chat.observations.include-completion=true")
+ .run(context -> {
+ assertThat(context).hasSingleBean(ChatModelCompletionObservationHandler.class);
+ });
+ }
+
+ @Test
+ void completionHandlerDisabled() {
contextRunner.withPropertyValues("spring.ai.chat.observations.include-completion=true").run(context -> {
- assertThat(context).hasSingleBean(ChatModelCompletionObservationFilter.class);
+ assertThat(context).doesNotHaveBean(ChatModelCompletionObservationHandler.class);
});
}