diff --git a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/pom.xml b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/pom.xml
index 27c4a24d3..2054117d0 100644
--- a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/pom.xml
+++ b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/pom.xml
@@ -30,6 +30,12 @@
${project.parent.version}
+
+ io.micrometer
+ micrometer-tracing
+ true
+
+
org.springframework.boot
diff --git a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java
index 2068f2722..9341ea1ae 100644
--- a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java
+++ b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java
@@ -17,22 +17,23 @@
package org.springframework.ai.model.chat.client.autoconfigure;
import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.tracing.Tracer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.ChatClientCustomizer;
+import org.springframework.ai.chat.client.observation.ChatClientObservationContext;
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
-import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationFilter;
+import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationHandler;
import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.observation.TracingAwareLoggingObservationHandler;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
-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;
import org.springframework.context.annotation.Scope;
/**
@@ -47,9 +48,11 @@ import org.springframework.context.annotation.Scope;
* @author Josh Long
* @author Arjen Poutsma
* @author Thomas Vitale
+ * @author Jonatan Ivanov
* @since 1.0.0
*/
-@AutoConfiguration
+@AutoConfiguration(
+ afterName = { "org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration" })
@ConditionalOnClass(ChatClient.class)
@EnableConfigurationProperties(ChatClientBuilderProperties.class)
@ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
@@ -58,6 +61,11 @@ public class ChatClientAutoConfiguration {
private static final Logger logger = LoggerFactory.getLogger(ChatClientAutoConfiguration.class);
+ private static void logPromptContentWarning() {
+ logger.warn(
+ "You have enabled logging out the ChatClient prompt content with the risk of exposing sensitive or private information. Please, be careful!");
+ }
+
@Bean
@ConditionalOnMissingBean
ChatClientBuilderConfigurer chatClientBuilderConfigurer(ObjectProvider customizerProvider) {
@@ -79,14 +87,37 @@ public class ChatClientAutoConfiguration {
return chatClientBuilderConfigurer.configure(builder);
}
- @Bean
- @ConditionalOnMissingBean
- @ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations",
- name = "include-prompt", havingValue = "true")
- ChatClientPromptContentObservationFilter chatClientPromptContentObservationFilter() {
- logger.warn(
- "You have enabled the inclusion of the ChatClient prompt content in the observations, with the risk of exposing sensitive or private information. Please, be careful!");
- return new ChatClientPromptContentObservationFilter();
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnClass(Tracer.class)
+ @ConditionalOnBean(Tracer.class)
+ static class TracerPresentObservationConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean(value = ChatClientPromptContentObservationHandler.class,
+ name = "chatClientPromptContentObservationHandler")
+ @ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations",
+ name = "log-prompt", havingValue = "true")
+ TracingAwareLoggingObservationHandler chatClientPromptContentObservationHandler(
+ Tracer tracer) {
+ logPromptContentWarning();
+ return new TracingAwareLoggingObservationHandler<>(new ChatClientPromptContentObservationHandler(), tracer);
+ }
+
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnMissingClass("io.micrometer.tracing.Tracer")
+ static class TracerNotPresentObservationConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations",
+ name = "log-prompt", havingValue = "true")
+ ChatClientPromptContentObservationHandler chatClientPromptContentObservationHandler() {
+ logPromptContentWarning();
+ return new ChatClientPromptContentObservationHandler();
+ }
+
}
}
diff --git a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientBuilderProperties.java b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientBuilderProperties.java
index 38f9bb39e..9e5809239 100644
--- a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientBuilderProperties.java
+++ b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientBuilderProperties.java
@@ -55,16 +55,16 @@ public class ChatClientBuilderProperties {
public static class Observations {
/**
- * Whether to include the prompt content in the observations.
+ * Whether to log the prompt content in the observations.
*/
- private boolean includePrompt = false;
+ private boolean logPrompt = false;
- public boolean isIncludePrompt() {
- return this.includePrompt;
+ public boolean isLogPrompt() {
+ return this.logPrompt;
}
- public void setIncludePrompt(boolean includePrompt) {
- this.includePrompt = includePrompt;
+ public void setLogPrompt(boolean logPrompt) {
+ this.logPrompt = logPrompt;
}
}
diff --git a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/test/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientObservationAutoConfigurationTests.java b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/test/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientObservationAutoConfigurationTests.java
index 8ab54ff0a..d85ff3f02 100644
--- a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/test/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientObservationAutoConfigurationTests.java
+++ b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/test/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientObservationAutoConfigurationTests.java
@@ -16,10 +16,11 @@
package org.springframework.ai.model.chat.client.autoconfigure;
+import io.micrometer.tracing.Tracer;
import org.junit.jupiter.api.Test;
-
-import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationFilter;
+import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationHandler;
import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
@@ -29,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThat;
*
* @author Christian Tzolov
* @author Thomas Vitale
+ * @author Jonatan Ivanov
*/
class ChatClientObservationAutoConfigurationTests {
@@ -36,15 +38,16 @@ class ChatClientObservationAutoConfigurationTests {
.withConfiguration(AutoConfigurations.of(ChatClientAutoConfiguration.class));
@Test
- void promptContentFilterDefault() {
+ void promptContentHandlerDefault() {
this.contextRunner
- .run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationFilter.class));
+ .run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationHandler.class));
}
@Test
- void promptContentFilterEnabled() {
- this.contextRunner.withPropertyValues("spring.ai.chat.client.observations.include-prompt=true")
- .run(context -> assertThat(context).hasSingleBean(ChatClientPromptContentObservationFilter.class));
+ void promptContentHandlerEnabled() {
+ this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class))
+ .withPropertyValues("spring.ai.chat.client.observations.log-prompt=true")
+ .run(context -> assertThat(context).hasSingleBean(ChatClientPromptContentObservationHandler.class));
}
}
diff --git a/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/pom.xml b/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/pom.xml
index 9c6e7283c..69abe5ccc 100644
--- a/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/pom.xml
+++ b/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/pom.xml
@@ -32,7 +32,7 @@
io.micrometer
- micrometer-tracing-bridge-otel
+ micrometer-tracing
true
diff --git a/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/main/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationAutoConfiguration.java b/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/main/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationAutoConfiguration.java
index d4a4dc6cd..d2471040c 100644
--- a/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/main/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationAutoConfiguration.java
+++ b/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/main/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationAutoConfiguration.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -16,41 +16,35 @@
package org.springframework.ai.model.chat.observation.autoconfigure;
-import java.util.List;
-
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.tracing.Tracer;
-import io.micrometer.tracing.otel.bridge.OtelTracer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-
import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationContext;
import org.springframework.ai.chat.client.observation.ChatClientObservationContext;
import org.springframework.ai.chat.model.ChatModel;
-import org.springframework.ai.chat.observation.ChatModelCompletionObservationFilter;
import org.springframework.ai.chat.observation.ChatModelCompletionObservationHandler;
import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler;
import org.springframework.ai.chat.observation.ChatModelObservationContext;
-import org.springframework.ai.chat.observation.ChatModelPromptContentObservationFilter;
import org.springframework.ai.chat.observation.ChatModelPromptContentObservationHandler;
import org.springframework.ai.embedding.observation.EmbeddingModelObservationContext;
import org.springframework.ai.image.observation.ImageModelObservationContext;
import org.springframework.ai.model.observation.ErrorLoggingObservationHandler;
+import org.springframework.ai.observation.TracingAwareLoggingObservationHandler;
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.ConditionalOnMissingClass;
-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;
+import java.util.List;
+
/**
* Auto-configuration for Spring AI chat model observations.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
* @since 1.0.0
*/
@AutoConfiguration(
@@ -63,12 +57,12 @@ public class ChatObservationAutoConfiguration {
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!");
+ "You have enabled logging out the prompt content with the risk of exposing sensitive or private information. Please, be careful!");
}
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!");
+ "You have enabled logging out the completion content with the risk of exposing sensitive or private information. Please, be careful!");
}
@Bean
@@ -78,72 +72,38 @@ public class ChatObservationAutoConfiguration {
return new ChatModelMeterObservationHandler(meterRegistry.getObject());
}
- /**
- * 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();
- }
-
- }
-
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Tracer.class)
@ConditionalOnBean(Tracer.class)
- static class TracingChatContentObservationConfiguration {
+ static class TracerPresentObservationConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean(value = ChatModelPromptContentObservationHandler.class,
+ name = "chatModelPromptContentObservationHandler")
+ @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "log-prompt",
+ havingValue = "true")
+ TracingAwareLoggingObservationHandler chatModelPromptContentObservationHandler(
+ Tracer tracer) {
+ logPromptContentWarning();
+ return new TracingAwareLoggingObservationHandler<>(new ChatModelPromptContentObservationHandler(), tracer);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean(value = ChatModelCompletionObservationHandler.class,
+ name = "chatModelCompletionObservationHandler")
+ @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "log-completion",
+ havingValue = "true")
+ TracingAwareLoggingObservationHandler chatModelCompletionObservationHandler(
+ Tracer tracer) {
+ logCompletionWarning();
+ return new TracingAwareLoggingObservationHandler<>(new ChatModelCompletionObservationHandler(), tracer);
+ }
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-error-logging",
havingValue = "true")
- public ErrorLoggingObservationHandler errorLoggingObservationHandler(Tracer tracer) {
+ ErrorLoggingObservationHandler errorLoggingObservationHandler(Tracer tracer) {
return new ErrorLoggingObservationHandler(tracer,
List.of(EmbeddingModelObservationContext.class, ImageModelObservationContext.class,
ChatModelObservationContext.class, ChatClientObservationContext.class,
@@ -152,4 +112,28 @@ public class ChatObservationAutoConfiguration {
}
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnMissingClass("io.micrometer.tracing.Tracer")
+ static class TracerNotPresentObservationConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "log-prompt",
+ havingValue = "true")
+ ChatModelPromptContentObservationHandler chatModelPromptContentObservationHandler() {
+ logPromptContentWarning();
+ return new ChatModelPromptContentObservationHandler();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "log-completion",
+ havingValue = "true")
+ ChatModelCompletionObservationHandler chatModelCompletionObservationHandler() {
+ logCompletionWarning();
+ return new ChatModelCompletionObservationHandler();
+ }
+
+ }
+
}
diff --git a/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/main/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationProperties.java b/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/main/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationProperties.java
index be37d1b26..1d6c4620d 100644
--- a/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/main/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationProperties.java
+++ b/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/main/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationProperties.java
@@ -22,6 +22,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* Configuration properties for chat model observations.
*
* @author Thomas Vitale
+ * @author Christian Tzolov
* @since 1.0.0
*/
@ConfigurationProperties(ChatObservationProperties.CONFIG_PREFIX)
@@ -35,9 +36,9 @@ public class ChatObservationProperties {
private boolean includeCompletion = false;
/**
- * Whether to include the prompt content in the observations.
+ * Whether to log the prompt content in the observations.
*/
- private boolean includePrompt = false;
+ private boolean logPrompt = false;
/**
* Whether to include error logging in the observations.
@@ -52,12 +53,12 @@ public class ChatObservationProperties {
this.includeCompletion = includeCompletion;
}
- public boolean isIncludePrompt() {
- return this.includePrompt;
+ public boolean isLogPrompt() {
+ return this.logPrompt;
}
- public void setIncludePrompt(boolean includePrompt) {
- this.includePrompt = includePrompt;
+ public void setLogPrompt(boolean logPrompt) {
+ this.logPrompt = logPrompt;
}
public boolean isIncludeErrorLogging() {
diff --git a/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/test/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationAutoConfigurationTests.java b/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/test/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationAutoConfigurationTests.java
index 3ac3f12ca..145c51e9c 100644
--- a/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/test/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationAutoConfigurationTests.java
+++ b/auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation/src/test/java/org/springframework/ai/model/chat/observation/autoconfigure/ChatObservationAutoConfigurationTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -17,17 +17,14 @@
package org.springframework.ai.model.chat.observation.autoconfigure;
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 io.micrometer.tracing.Tracer;
import org.junit.jupiter.api.Test;
-import org.springframework.ai.chat.observation.ChatModelCompletionObservationFilter;
import org.springframework.ai.chat.observation.ChatModelCompletionObservationHandler;
import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler;
-import org.springframework.ai.chat.observation.ChatModelPromptContentObservationFilter;
import org.springframework.ai.chat.observation.ChatModelPromptContentObservationHandler;
import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
@@ -36,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Unit tests for {@link ChatObservationAutoConfiguration}.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
*/
class ChatObservationAutoConfigurationTests {
@@ -53,12 +51,6 @@ class ChatObservationAutoConfigurationTests {
this.contextRunner.run(context -> assertThat(context).doesNotHaveBean(ChatModelMeterObservationHandler.class));
}
- @Test
- void promptFilterDefault() {
- this.contextRunner
- .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationFilter.class));
- }
-
@Test
void promptHandlerDefault() {
this.contextRunner
@@ -67,24 +59,17 @@ class ChatObservationAutoConfigurationTests {
@Test
void promptHandlerEnabled() {
- this.contextRunner
- .withBean(OtelTracer.class, OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null)
- .withPropertyValues("spring.ai.chat.observations.include-prompt=true")
+ this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class))
+ .withPropertyValues("spring.ai.chat.observations.log-prompt=true")
.run(context -> assertThat(context).hasSingleBean(ChatModelPromptContentObservationHandler.class));
}
@Test
void promptHandlerDisabled() {
- this.contextRunner.withPropertyValues("spring.ai.chat.observations.include-prompt=true")
+ this.contextRunner.withPropertyValues("spring.ai.chat.observations.log-prompt=false")
.run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class));
}
- @Test
- void completionFilterDefault() {
- this.contextRunner
- .run(context -> assertThat(context).doesNotHaveBean(ChatModelCompletionObservationFilter.class));
- }
-
@Test
void completionHandlerDefault() {
this.contextRunner
@@ -93,15 +78,14 @@ class ChatObservationAutoConfigurationTests {
@Test
void completionHandlerEnabled() {
- this.contextRunner
- .withBean(OtelTracer.class, OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null)
- .withPropertyValues("spring.ai.chat.observations.include-completion=true")
+ this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class))
+ .withPropertyValues("spring.ai.chat.observations.log-completion=true")
.run(context -> assertThat(context).hasSingleBean(ChatModelCompletionObservationHandler.class));
}
@Test
void completionHandlerDisabled() {
- this.contextRunner.withPropertyValues("spring.ai.chat.observations.include-completion=true")
+ this.contextRunner.withPropertyValues("spring.ai.chat.observations.log-completion=false")
.run(context -> assertThat(context).doesNotHaveBean(ChatModelCompletionObservationHandler.class));
}
diff --git a/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/pom.xml b/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/pom.xml
index babbe1566..ed4957d86 100644
--- a/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/pom.xml
+++ b/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/pom.xml
@@ -30,6 +30,12 @@
${project.parent.version}
+
+ io.micrometer
+ micrometer-tracing
+ true
+
+
org.springframework.boot
diff --git a/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/main/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationAutoConfiguration.java b/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/main/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationAutoConfiguration.java
index f97e12f03..e2efd755b 100644
--- a/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/main/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationAutoConfiguration.java
+++ b/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/main/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationAutoConfiguration.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -16,22 +16,24 @@
package org.springframework.ai.model.image.observation.autoconfigure;
+import io.micrometer.tracing.Tracer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-
import org.springframework.ai.image.ImageModel;
-import org.springframework.ai.image.observation.ImageModelPromptContentObservationFilter;
+import org.springframework.ai.image.observation.ImageModelObservationContext;
+import org.springframework.ai.image.observation.ImageModelPromptContentObservationHandler;
+import org.springframework.ai.observation.TracingAwareLoggingObservationHandler;
import org.springframework.boot.autoconfigure.AutoConfiguration;
-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 image model observations.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
* @since 1.0.0
*/
@AutoConfiguration(
@@ -42,14 +44,42 @@ public class ImageObservationAutoConfiguration {
private static final Logger logger = LoggerFactory.getLogger(ImageObservationAutoConfiguration.class);
- @Bean
- @ConditionalOnMissingBean
- @ConditionalOnProperty(prefix = ImageObservationProperties.CONFIG_PREFIX, name = "include-prompt",
- havingValue = "true")
- ImageModelPromptContentObservationFilter imageModelPromptObservationFilter() {
+ private static void logPromptContentWarning() {
logger.warn(
- "You have enabled the inclusion of the image prompt content in the observations, with the risk of exposing sensitive or private information. Please, be careful!");
- return new ImageModelPromptContentObservationFilter();
+ "You have enabled logging out the image prompt content with the risk of exposing sensitive or private information. Please, be careful!");
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnClass(Tracer.class)
+ @ConditionalOnBean(Tracer.class)
+ static class TracerPresentObservationConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean(value = ImageModelPromptContentObservationHandler.class,
+ name = "imageModelPromptContentObservationHandler")
+ @ConditionalOnProperty(prefix = ImageObservationProperties.CONFIG_PREFIX, name = "log-prompt",
+ havingValue = "true")
+ TracingAwareLoggingObservationHandler imageModelPromptContentObservationHandler(
+ Tracer tracer) {
+ logPromptContentWarning();
+ return new TracingAwareLoggingObservationHandler<>(new ImageModelPromptContentObservationHandler(), tracer);
+ }
+
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnMissingClass("io.micrometer.tracing.Tracer")
+ static class TracerNotPresentObservationConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = ImageObservationProperties.CONFIG_PREFIX, name = "log-prompt",
+ havingValue = "true")
+ ImageModelPromptContentObservationHandler imageModelPromptContentObservationHandler() {
+ logPromptContentWarning();
+ return new ImageModelPromptContentObservationHandler();
+ }
+
}
}
diff --git a/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/main/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationProperties.java b/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/main/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationProperties.java
index 7d372d986..bcc845356 100644
--- a/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/main/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationProperties.java
+++ b/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/main/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationProperties.java
@@ -22,6 +22,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* Configuration properties for image model observations.
*
* @author Thomas Vitale
+ * @author Christian Tzolov
* @since 1.0.0
*/
@ConfigurationProperties(ImageObservationProperties.CONFIG_PREFIX)
@@ -30,16 +31,16 @@ public class ImageObservationProperties {
public static final String CONFIG_PREFIX = "spring.ai.image.observations";
/**
- * Whether to include the prompt content in the observations.
+ * Whether to log the prompt content in the observations.
*/
- private boolean includePrompt = false;
+ private boolean logPrompt = false;
- public boolean isIncludePrompt() {
- return this.includePrompt;
+ public boolean isLogPrompt() {
+ return this.logPrompt;
}
- public void setIncludePrompt(boolean includePrompt) {
- this.includePrompt = includePrompt;
+ public void setLogPrompt(boolean logPrompt) {
+ this.logPrompt = logPrompt;
}
}
diff --git a/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/test/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationAutoConfigurationTests.java b/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/test/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationAutoConfigurationTests.java
index 23417b67b..b75aa050f 100644
--- a/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/test/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationAutoConfigurationTests.java
+++ b/auto-configurations/models/image/observation/spring-ai-autoconfigure-model-image-observation/src/test/java/org/springframework/ai/model/image/observation/autoconfigure/ImageObservationAutoConfigurationTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -16,10 +16,12 @@
package org.springframework.ai.model.image.observation.autoconfigure;
+import io.micrometer.tracing.Tracer;
import org.junit.jupiter.api.Test;
-import org.springframework.ai.image.observation.ImageModelPromptContentObservationFilter;
+import org.springframework.ai.image.observation.ImageModelPromptContentObservationHandler;
import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
@@ -28,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Unit tests for {@link ImageObservationAutoConfiguration}.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
*/
class ImageObservationAutoConfigurationTests {
@@ -35,15 +38,16 @@ class ImageObservationAutoConfigurationTests {
.withConfiguration(AutoConfigurations.of(ImageObservationAutoConfiguration.class));
@Test
- void promptFilterDefault() {
+ void promptHandlerDefault() {
this.contextRunner
- .run(context -> assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationFilter.class));
+ .run(context -> assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationHandler.class));
}
@Test
- void promptFilterEnabled() {
- this.contextRunner.withPropertyValues("spring.ai.image.observations.include-prompt=true")
- .run(context -> assertThat(context).hasSingleBean(ImageModelPromptContentObservationFilter.class));
+ void promptHandlerEnabled() {
+ this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class))
+ .withPropertyValues("spring.ai.image.observations.log-prompt=true")
+ .run(context -> assertThat(context).hasSingleBean(ImageModelPromptContentObservationHandler.class));
}
}
diff --git a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/pom.xml b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/pom.xml
index db729f3cc..0c8d58d90 100644
--- a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/pom.xml
+++ b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/pom.xml
@@ -45,7 +45,7 @@
io.micrometer
- micrometer-tracing-bridge-otel
+ micrometer-tracing
true
diff --git a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/src/main/java/org/springframework/ai/vectorstore/observation/autoconfigure/VectorStoreObservationAutoConfiguration.java b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/src/main/java/org/springframework/ai/vectorstore/observation/autoconfigure/VectorStoreObservationAutoConfiguration.java
index e3651c7fd..022539d15 100644
--- a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/src/main/java/org/springframework/ai/vectorstore/observation/autoconfigure/VectorStoreObservationAutoConfiguration.java
+++ b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/src/main/java/org/springframework/ai/vectorstore/observation/autoconfigure/VectorStoreObservationAutoConfiguration.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -16,19 +16,15 @@
package org.springframework.ai.vectorstore.observation.autoconfigure;
-import io.micrometer.tracing.otel.bridge.OtelTracer;
+import io.micrometer.tracing.Tracer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-
+import org.springframework.ai.observation.TracingAwareLoggingObservationHandler;
import org.springframework.ai.vectorstore.VectorStore;
-import org.springframework.ai.vectorstore.observation.VectorStoreQueryResponseObservationFilter;
+import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
import org.springframework.ai.vectorstore.observation.VectorStoreQueryResponseObservationHandler;
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.ConditionalOnMissingClass;
-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;
@@ -38,6 +34,7 @@ import org.springframework.context.annotation.Configuration;
*
* @author Christian Tzolov
* @author Thomas Vitale
+ * @author Jonatan Ivanov
* @since 1.0.0
*/
@AutoConfiguration(
@@ -50,24 +47,35 @@ public class VectorStoreObservationAutoConfiguration {
private static void logQueryResponseContentWarning() {
logger.warn(
- "You have enabled the inclusion of the query response content in the observations, with the risk of exposing sensitive or private information. Please, be careful!");
+ "You have enabled logging out of the query response content with the risk of exposing sensitive or private information. Please, be careful!");
}
- /**
- * The query response 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 PrimaryVectorStoreQueryResponseContentObservationConfiguration {
+ @ConditionalOnClass(Tracer.class)
+ @ConditionalOnBean(Tracer.class)
+ static class TracerPresentObservationConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean(value = VectorStoreQueryResponseObservationHandler.class,
+ name = "vectorStoreQueryResponseObservationHandler")
+ @ConditionalOnProperty(prefix = VectorStoreObservationProperties.CONFIG_PREFIX, name = "log-query-response",
+ havingValue = "true")
+ TracingAwareLoggingObservationHandler vectorStoreQueryResponseObservationHandler(
+ Tracer tracer) {
+ logQueryResponseContentWarning();
+ return new TracingAwareLoggingObservationHandler<>(new VectorStoreQueryResponseObservationHandler(),
+ tracer);
+ }
+
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnMissingClass("io.micrometer.tracing.Tracer")
+ static class TracerNotPresentObservationConfiguration {
@Bean
@ConditionalOnMissingBean
- @ConditionalOnProperty(prefix = VectorStoreObservationProperties.CONFIG_PREFIX, name = "include-query-response",
+ @ConditionalOnProperty(prefix = VectorStoreObservationProperties.CONFIG_PREFIX, name = "log-query-response",
havingValue = "true")
VectorStoreQueryResponseObservationHandler vectorStoreQueryResponseObservationHandler() {
logQueryResponseContentWarning();
@@ -76,19 +84,4 @@ public class VectorStoreObservationAutoConfiguration {
}
- @Configuration(proxyBeanMethods = false)
- @ConditionalOnMissingClass("io.micrometer.tracing.otel.bridge.OtelTracer")
- static class FallbackVectorStoreQueryResponseContentObservationConfiguration {
-
- @Bean
- @ConditionalOnMissingBean
- @ConditionalOnProperty(prefix = VectorStoreObservationProperties.CONFIG_PREFIX, name = "include-query-response",
- havingValue = "true")
- VectorStoreQueryResponseObservationFilter vectorStoreQueryResponseContentObservationFilter() {
- logQueryResponseContentWarning();
- return new VectorStoreQueryResponseObservationFilter();
- }
-
- }
-
}
diff --git a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/src/test/java/org/springframework/ai/vectorstore/observation/autoconfigure/VectorStoreObservationAutoConfigurationTests.java b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/src/test/java/org/springframework/ai/vectorstore/observation/autoconfigure/VectorStoreObservationAutoConfigurationTests.java
index 3f34db276..482edb177 100644
--- a/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/src/test/java/org/springframework/ai/vectorstore/observation/autoconfigure/VectorStoreObservationAutoConfigurationTests.java
+++ b/auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-observation/src/test/java/org/springframework/ai/vectorstore/observation/autoconfigure/VectorStoreObservationAutoConfigurationTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -16,14 +16,12 @@
package org.springframework.ai.vectorstore.observation.autoconfigure;
-import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
-import io.micrometer.tracing.otel.bridge.OtelTracer;
-import io.opentelemetry.api.OpenTelemetry;
+import io.micrometer.tracing.Tracer;
import org.junit.jupiter.api.Test;
-import org.springframework.ai.vectorstore.observation.VectorStoreQueryResponseObservationFilter;
import org.springframework.ai.vectorstore.observation.VectorStoreQueryResponseObservationHandler;
import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
@@ -32,18 +30,13 @@ import static org.assertj.core.api.Assertions.assertThat;
* Unit tests for {@link VectorStoreObservationAutoConfiguration}.
*
* @author Christian Tzolov
+ * @author Jonatan Ivanov
*/
class VectorStoreObservationAutoConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(VectorStoreObservationAutoConfiguration.class));
- @Test
- void queryResponseFilterDefault() {
- this.contextRunner
- .run(context -> assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationFilter.class));
- }
-
@Test
void queryResponseHandlerDefault() {
this.contextRunner
@@ -52,10 +45,15 @@ class VectorStoreObservationAutoConfigurationTests {
@Test
void queryResponseHandlerEnabled() {
- this.contextRunner
- .withBean(OtelTracer.class, OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null)
- .withPropertyValues("spring.ai.vectorstore.observations.include-query-response=true")
+ this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class))
+ .withPropertyValues("spring.ai.vectorstore.observations.log-query-response=true")
.run(context -> assertThat(context).hasSingleBean(VectorStoreQueryResponseObservationHandler.class));
}
+ @Test
+ void queryResponseHandlerDisabled() {
+ this.contextRunner.withPropertyValues("spring.ai.vectorstore.observations.log-query-response=false")
+ .run(context -> assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationHandler.class));
+ }
+
}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java
index b1c2ee2e5..a7428dbe2 100644
--- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -20,7 +20,6 @@ import io.micrometer.common.docs.KeyName;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.docs.ObservationDocumentation;
-import org.springframework.ai.observation.conventions.AiObservationAttributes;
/**
* Documented conventions for chat client observations.
@@ -107,19 +106,7 @@ public enum ChatClientObservationDocumentation implements ObservationDocumentati
public String asString() {
return "spring.ai.chat.client.tool.names";
}
- },
-
- // Content
-
- /**
- * The full prompt requested to be sent to the model.
- */
- PROMPT {
- @Override
- public String asString() {
- return AiObservationAttributes.PROMPT.value();
- }
- },
+ }
}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilter.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationHandler.java
similarity index 60%
rename from spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilter.java
rename to spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationHandler.java
index eb683886b..dcd3711e8 100644
--- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilter.java
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationHandler.java
@@ -17,36 +17,29 @@
package org.springframework.ai.chat.client.observation;
import io.micrometer.observation.Observation;
-import io.micrometer.observation.ObservationFilter;
-import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
-import org.springframework.ai.observation.tracing.TracingHelper;
+import io.micrometer.observation.ObservationHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.observation.ObservabilityHelper;
import org.springframework.util.CollectionUtils;
import java.util.HashMap;
import java.util.Map;
/**
- * An {@link ObservationFilter} to include the chat client prompt content in the
- * observation.
+ * Handler for emitting the chat client prompt content to logs.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
* @since 1.0.0
*/
-public final class ChatClientPromptContentObservationFilter implements ObservationFilter {
+public class ChatClientPromptContentObservationHandler implements ObservationHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(ChatClientPromptContentObservationHandler.class);
@Override
- public Observation.Context map(Observation.Context context) {
- if (!(context instanceof ChatClientObservationContext chatClientObservationContext)) {
- return context;
- }
-
- var prompts = processPrompt(chatClientObservationContext);
-
- chatClientObservationContext
- .addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT
- .withValue(TracingHelper.concatenateMaps(prompts)));
-
- return chatClientObservationContext;
+ public void onStop(ChatClientObservationContext context) {
+ logger.debug("Chat Client Prompt Content:\n{}", ObservabilityHelper.concatenateEntries(processPrompt(context)));
}
private Map processPrompt(ChatClientObservationContext context) {
@@ -62,4 +55,9 @@ public final class ChatClientPromptContentObservationFilter implements Observati
return messages;
}
+ @Override
+ public boolean supportsContext(Observation.Context context) {
+ return context instanceof ChatClientObservationContext;
+ }
+
}
diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java
index 431ce9c84..16e3d19af 100644
--- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java
+++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java
@@ -16,22 +16,21 @@
package org.springframework.ai.chat.client.observation;
-import java.util.ArrayList;
-
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
-
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.LowCardinalityKeyNames;
import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
+import org.springframework.ai.observation.ObservabilityHelper;
import org.springframework.ai.observation.conventions.SpringAiKind;
-import org.springframework.ai.observation.tracing.TracingHelper;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
+import java.util.ArrayList;
+
/**
* Default conventions to populate observations for chat client workflows.
*
@@ -103,7 +102,7 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva
}
var advisorNames = context.getAdvisors().stream().map(Advisor::getName).toList();
return keyValues.and(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(),
- TracingHelper.concatenateStrings(advisorNames));
+ ObservabilityHelper.concatenateStrings(advisorNames));
}
protected KeyValues conversationId(KeyValues keyValues, ChatClientObservationContext context) {
@@ -143,7 +142,7 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva
return keyValues.and(
ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_NAMES.asString(),
- TracingHelper.concatenateStrings(toolNames.stream().sorted().toList()));
+ ObservabilityHelper.concatenateStrings(toolNames.stream().sorted().toList()));
}
}
diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilterTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilterTests.java
deleted file mode 100644
index fac92843f..000000000
--- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilterTests.java
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * Copyright 2023-2025 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.client.observation;
-
-import io.micrometer.common.KeyValue;
-import io.micrometer.observation.Observation;
-import org.junit.jupiter.api.Test;
-import org.springframework.ai.chat.client.ChatClientRequest;
-import org.springframework.ai.chat.messages.SystemMessage;
-import org.springframework.ai.chat.messages.UserMessage;
-import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
-import org.springframework.ai.chat.prompt.Prompt;
-
-import java.util.List;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Unit tests for {@link ChatClientPromptContentObservationFilter}.
- *
- * @author Thomas Vitale
- */
-class ChatClientPromptContentObservationFilterTests {
-
- private final ChatClientPromptContentObservationFilter observationFilter = new ChatClientPromptContentObservationFilter();
-
- @Test
- void whenNotSupportedObservationContextThenReturnOriginalContext() {
- var expectedContext = new Observation.Context();
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenEmptyPromptThenReturnOriginalContext() {
- var expectedContext = ChatClientObservationContext.builder()
- .request(ChatClientRequest.builder().prompt(new Prompt(List.of())).build())
- .build();
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenPromptWithTextThenAugmentContext() {
- var originalContext = ChatClientObservationContext.builder()
- .request(ChatClientRequest.builder().prompt(new Prompt("supercalifragilisticexpialidocious")).build())
- .build();
-
- var augmentedContext = this.observationFilter.map(originalContext);
-
- assertThat(augmentedContext.getHighCardinalityKeyValues())
- .contains(KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.PROMPT.asString(), """
- ["user":"supercalifragilisticexpialidocious"]"""));
- }
-
- @Test
- void whenPromptWithMessagesThenAugmentContext() {
- var originalContext = ChatClientObservationContext.builder()
- .request(ChatClientRequest.builder()
- .prompt(new Prompt(List.of(new SystemMessage("you're a chimney sweep"),
- new UserMessage("supercalifragilisticexpialidocious"))))
- .build())
- .build();
-
- var augmentedContext = this.observationFilter.map(originalContext);
-
- assertThat(augmentedContext.getHighCardinalityKeyValues())
- .contains(KeyValue.of(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT.asString(), """
- ["system":"you're a chimney sweep", "user":"supercalifragilisticexpialidocious"]"""));
- }
-
-}
diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationHandlerTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationHandlerTests.java
new file mode 100644
index 000000000..616dba0e1
--- /dev/null
+++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationHandlerTests.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2023-2025 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.client.observation;
+
+import io.micrometer.observation.Observation;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.ai.chat.client.ChatClientRequest;
+import org.springframework.ai.chat.messages.SystemMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link ChatClientPromptContentObservationHandler}.
+ *
+ * @author Thomas Vitale
+ * @author Jonatan Ivanov
+ */
+@ExtendWith(OutputCaptureExtension.class)
+class ChatClientPromptContentObservationHandlerTests {
+
+ private final ChatClientPromptContentObservationHandler observationHandler = new ChatClientPromptContentObservationHandler();
+
+ @Test
+ void whenNotSupportedObservationContextThenReturnFalse() {
+ var context = new Observation.Context();
+ assertThat(this.observationHandler.supportsContext(context)).isFalse();
+ }
+
+ @Test
+ void whenSupportedObservationContextThenReturnTrue() {
+ var context = ChatClientObservationContext.builder()
+ .request(ChatClientRequest.builder().prompt(new Prompt(List.of())).build())
+ .build();
+ assertThat(this.observationHandler.supportsContext(context)).isTrue();
+ }
+
+ @Test
+ void whenEmptyPromptThenOutputNothing(CapturedOutput output) {
+ var context = ChatClientObservationContext.builder()
+ .request(ChatClientRequest.builder().prompt(new Prompt(List.of())).build())
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Chat Client Prompt Content:
+ []
+ """);
+ }
+
+ @Test
+ void whenPromptWithTextThenOutputIt(CapturedOutput output) {
+ var context = ChatClientObservationContext.builder()
+ .request(ChatClientRequest.builder().prompt(new Prompt("supercalifragilisticexpialidocious")).build())
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Chat Client Prompt Content:
+ ["user":"supercalifragilisticexpialidocious"]
+ """);
+ }
+
+ @Test
+ void whenPromptWithMessagesThenOutputIt(CapturedOutput output) {
+ var context = ChatClientObservationContext.builder()
+ .request(ChatClientRequest.builder()
+ .prompt(new Prompt(List.of(new SystemMessage("you're a chimney sweep"),
+ new UserMessage("supercalifragilisticexpialidocious"))))
+ .build())
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Chat Client Prompt Content:
+ ["system":"you're a chimney sweep", "user":"supercalifragilisticexpialidocious"]
+ """);
+ }
+
+}
diff --git a/spring-ai-commons/pom.xml b/spring-ai-commons/pom.xml
index 4ad96220e..6d486c7bb 100644
--- a/spring-ai-commons/pom.xml
+++ b/spring-ai-commons/pom.xml
@@ -53,9 +53,15 @@
context-propagation
+
+ org.slf4j
+ slf4j-api
+ true
+
+
io.micrometer
- micrometer-tracing-bridge-otel
+ micrometer-tracing
true
diff --git a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationContentProcessor.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/ObservabilityHelper.java
similarity index 50%
rename from spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationContentProcessor.java
rename to spring-ai-commons/src/main/java/org/springframework/ai/observation/ObservabilityHelper.java
index e73ca4603..ddeae6b0a 100644
--- a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationContentProcessor.java
+++ b/spring-ai-commons/src/main/java/org/springframework/ai/observation/ObservabilityHelper.java
@@ -14,29 +14,32 @@
* limitations under the License.
*/
-package org.springframework.ai.vectorstore.observation;
+package org.springframework.ai.observation;
import java.util.List;
-
-import org.springframework.ai.document.Document;
-import org.springframework.util.CollectionUtils;
+import java.util.Map;
+import java.util.StringJoiner;
/**
- * Utilities to process the query content in observations for vector store operations.
+ * Utilities for observability.
*
* @author Thomas Vitale
*/
-public final class VectorStoreObservationContentProcessor {
+public final class ObservabilityHelper {
- private VectorStoreObservationContentProcessor() {
+ private ObservabilityHelper() {
}
- public static List documents(VectorStoreObservationContext context) {
- if (CollectionUtils.isEmpty(context.getQueryResponse())) {
- return List.of();
- }
+ public static String concatenateEntries(Map keyValues) {
+ var keyValuesJoiner = new StringJoiner(", ", "[", "]");
+ keyValues.forEach((key, value) -> keyValuesJoiner.add("\"" + key + "\":\"" + value + "\""));
+ return keyValuesJoiner.toString();
+ }
- return context.getQueryResponse().stream().map(Document::getText).toList();
+ public static String concatenateStrings(List strings) {
+ var stringsJoiner = new StringJoiner(", ", "[", "]");
+ strings.forEach(string -> stringsJoiner.add("\"" + string + "\""));
+ return stringsJoiner.toString();
}
}
diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/TracingAwareLoggingObservationHandler.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/TracingAwareLoggingObservationHandler.java
new file mode 100644
index 000000000..6826b2a96
--- /dev/null
+++ b/spring-ai-commons/src/main/java/org/springframework/ai/observation/TracingAwareLoggingObservationHandler.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2023-2025 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.observation;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationHandler;
+import io.micrometer.tracing.CurrentTraceContext;
+import io.micrometer.tracing.Span;
+import io.micrometer.tracing.Tracer;
+import io.micrometer.tracing.handler.TracingObservationHandler;
+
+/**
+ * An {@link ObservationHandler} that can wrap another one and makes the tracing data
+ * available for the {@link ObservationHandler#onStop(Observation.Context)} method. This
+ * handler can be used in cases where the logging library or needs access to the tracing
+ * data (i.e.: log correlation).
+ *
+ * @param type of handler context
+ * @author Jonatan Ivanov
+ * @since 1.0.0
+ */
+public class TracingAwareLoggingObservationHandler implements ObservationHandler {
+
+ private final ObservationHandler delegate;
+
+ private final Tracer tracer;
+
+ /**
+ * Creates a new instance.
+ * @param delegate ObservationHandler instance to delegate the handler method calls to
+ * @param tracer Tracer instance to create the scope with
+ */
+ public TracingAwareLoggingObservationHandler(ObservationHandler delegate, Tracer tracer) {
+ this.delegate = delegate;
+ this.tracer = tracer;
+ }
+
+ @Override
+ public void onStart(T context) {
+ this.delegate.onStart(context);
+ }
+
+ @Override
+ public void onError(T context) {
+ this.delegate.onError(context);
+ }
+
+ @Override
+ public void onEvent(Observation.Event event, T context) {
+ this.delegate.onEvent(event, context);
+ }
+
+ @Override
+ public void onScopeOpened(T context) {
+ this.delegate.onScopeOpened(context);
+ }
+
+ @Override
+ public void onScopeClosed(T context) {
+ this.delegate.onScopeClosed(context);
+ }
+
+ @Override
+ public void onScopeReset(T context) {
+ this.delegate.onScopeReset(context);
+ }
+
+ @Override
+ public void onStop(T context) {
+ TracingObservationHandler.TracingContext tracingContext = context
+ .getRequired(TracingObservationHandler.TracingContext.class);
+ Span currentSpan = tracingContext.getSpan();
+ if (currentSpan != null) {
+ try (CurrentTraceContext.Scope ignored = tracer.currentTraceContext().maybeScope(currentSpan.context())) {
+ this.delegate.onStop(context);
+ }
+ }
+ else {
+ this.delegate.onStop(context);
+ }
+ }
+
+ @Override
+ public boolean supportsContext(Observation.Context context) {
+ return this.delegate.supportsContext(context);
+ }
+
+}
diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationAttributes.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationAttributes.java
index 2931ec5c8..91b2eaf9c 100644
--- a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationAttributes.java
+++ b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationAttributes.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -122,18 +122,7 @@ public enum AiObservationAttributes {
/**
* The total number of tokens used in the model exchange.
*/
- USAGE_TOTAL_TOKENS("gen_ai.usage.total_tokens"),
-
- // GenAI Content
-
- /**
- * The full prompt sent to the model.
- */
- PROMPT("gen_ai.prompt"),
- /**
- * The full response received from the model.
- */
- COMPLETION("gen_ai.completion");
+ USAGE_TOTAL_TOKENS("gen_ai.usage.total_tokens");
private final String value;
diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationEventNames.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationEventNames.java
deleted file mode 100644
index 2b1e87ecf..000000000
--- a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationEventNames.java
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright 2023-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.observation.conventions;
-
-/**
- * Collection of event names used in AI observations. Based on the OpenTelemetry Semantic
- * Conventions for AI Systems.
- *
- * @author Thomas Vitale
- * @since 1.0.0
- * @see OTel
- * Semantic Conventions.
- */
-public enum AiObservationEventNames {
-
-// @formatter:off
-
- /**
- * Prompt for content generation.
- */
- CONTENT_PROMPT("gen_ai.content.prompt"),
-
- /**
- * Completion of content generation.
- */
- CONTENT_COMPLETION("gen_ai.content.completion");
-
- private final String value;
-
- AiObservationEventNames(String value) {
- this.value = value;
- }
-
- /**
- * Return the value of the event name.
- * @return the value of the event name
- */
- public String value() {
- return this.value;
- }
-
-// @formatter:on
-
-}
diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/VectorStoreObservationEventNames.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/VectorStoreObservationEventNames.java
deleted file mode 100644
index 702f9517f..000000000
--- a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/VectorStoreObservationEventNames.java
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Copyright 2023-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.observation.conventions;
-
-/**
- * Collection of event names used in vector store observations.
- *
- * @author Thomas Vitale
- * @since 1.0.0
- */
-public enum VectorStoreObservationEventNames {
-
-// @formatter:off
-
- /**
- * Query for content in the vector store.
- */
- CONTENT_QUERY_RESPONSE("db.vector.content.query.response");
-
- private final String value;
-
- VectorStoreObservationEventNames(String value) {
- this.value = value;
- }
-
- /**
- * Return the value of the event name.
- * @return the value of the event name
- */
- public String value() {
- return this.value;
- }
-
-// @formatter:on
-
-}
diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/tracing/TracingHelper.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/tracing/TracingHelper.java
deleted file mode 100644
index a675fd98e..000000000
--- a/spring-ai-commons/src/main/java/org/springframework/ai/observation/tracing/TracingHelper.java
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Copyright 2023-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.observation.tracing;
-
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.util.List;
-import java.util.Map;
-import java.util.StringJoiner;
-
-import io.micrometer.tracing.handler.TracingObservationHandler;
-import io.opentelemetry.api.trace.Span;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import org.springframework.lang.Nullable;
-
-/**
- * Utilities to prepare and process traces for observability.
- *
- * @author Thomas Vitale
- */
-public final class TracingHelper {
-
- private static final Logger logger = LoggerFactory.getLogger(TracingHelper.class);
-
- private TracingHelper() {
- }
-
- @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;
- }
-
- public static String concatenateMaps(Map keyValues) {
- var keyValuesJoiner = new StringJoiner(", ", "[", "]");
- keyValues.forEach((key, value) -> keyValuesJoiner.add("\"" + key + "\":\"" + value + "\""));
- return keyValuesJoiner.toString();
- }
-
- public static String concatenateStrings(List strings) {
- var stringsJoiner = new StringJoiner(", ", "[", "]");
- strings.forEach(string -> stringsJoiner.add("\"" + string + "\""));
- return stringsJoiner.toString();
- }
-
-}
diff --git a/spring-ai-commons/src/test/java/org/springframework/ai/observation/ObservabilityHelperTests.java b/spring-ai-commons/src/test/java/org/springframework/ai/observation/ObservabilityHelperTests.java
new file mode 100644
index 000000000..dcbbe53e4
--- /dev/null
+++ b/spring-ai-commons/src/test/java/org/springframework/ai/observation/ObservabilityHelperTests.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2023-2025 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.observation;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+/**
+ * Unit tests for {@link ObservabilityHelper}.
+ *
+ * @author Jonatan Ivanov
+ */
+class ObservabilityHelperTests {
+
+ @Test
+ void shouldGetEmptyBracketsForEmptyMap() {
+ assertThat(ObservabilityHelper.concatenateEntries(Map.of())).isEqualTo("[]");
+ }
+
+ @Test
+ void shouldGetEntriesForNonEmptyMap() {
+ TreeMap map = new TreeMap<>(Map.of("a", "1", "b", "2"));
+ assertThat(ObservabilityHelper.concatenateEntries(map)).isEqualTo("[\"a\":\"1\", \"b\":\"2\"]");
+ }
+
+ @Test
+ void shouldGetEmptyBracketsForEmptyList() {
+ assertThat(ObservabilityHelper.concatenateStrings(List.of())).isEqualTo("[]");
+ }
+
+ @Test
+ void shouldGetEntriesForNonEmptyList() {
+ assertThat(ObservabilityHelper.concatenateStrings(List.of("a", "b"))).isEqualTo("[\"a\", \"b\"]");
+ }
+
+}
diff --git a/spring-ai-commons/src/test/java/org/springframework/ai/observation/tracing/TracingHelperTests.java b/spring-ai-commons/src/test/java/org/springframework/ai/observation/tracing/TracingHelperTests.java
deleted file mode 100644
index 78ed778b2..000000000
--- a/spring-ai-commons/src/test/java/org/springframework/ai/observation/tracing/TracingHelperTests.java
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- * Copyright 2023-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.observation.tracing;
-
-import java.util.concurrent.TimeUnit;
-
-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 static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Unit tests for {@link TracingHelper}.
- *
- * @author Thomas Vitale
- */
-class TracingHelperTests {
-
- @Test
- void extractOtelSpanWhenTracingContextIsNull() {
- var actualOtelSpan = TracingHelper.extractOtelSpan(null);
- assertThat(actualOtelSpan).isNull();
- }
-
- @Test
- void extractOtelSpanWhenMethodDoesNotExist() {
- var tracingContext = new TracingObservationHandler.TracingContext();
- tracingContext.setSpan(Span.NOOP);
- var actualOtelSpan = TracingHelper.extractOtelSpan(tracingContext);
- assertThat(actualOtelSpan).isNull();
- }
-
- @Test
- void extractOtelSpanWhenSpanIsNotOpenTelemetry() {
- var tracingContext = new TracingObservationHandler.TracingContext();
- tracingContext.setSpan(new DemoOtherSpan());
- var actualOtelSpan = TracingHelper.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 = TracingHelper.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-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc
index 346ca12ed..f166ac5a5 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/observability/index.adoc
@@ -7,6 +7,19 @@ Spring AI provides metrics and tracing capabilities for its core components: `Ch
NOTE: Low cardinality keys will be added to metrics and traces, while high cardinality keys will only be added to traces.
+[WARNING]
+====
+**1.0.0-RC1 Breaking Changes**
+
+Following configuration properties have been renamed to better reflect their purpose:
+
+* `spring.ai.chat.client.observations.include-prompt` → `spring.ai.chat.client.observations.log-prompt`
+* `spring.ai.chat.observations.include-prompt` → `spring.ai.chat.observations.log-prompt`
+* `spring.ai.chat.observations.include-completion` → `spring.ai.chat.observations.log-completion`
+* `spring.ai.image.observations.include-prompt` → `spring.ai.image.observations.log-prompt`
+* `spring.ai.vectorstore.observations.include-query-response` → `spring.ai.vectorstore.observations.log-query-response`
+====
+
== Chat Client
The `spring.ai.chat.client` observations are recorded when a ChatClient `call()` or `stream()` operations are invoked.
@@ -46,25 +59,25 @@ They measure the time spent performing the invocation and propagate the related
The `ChatClient` prompt content is typically big and possibly containing sensitive information.
For those reasons, it is not exported by default.
-Spring AI supports exporting the prompt content as span attributes/events across all tracing backends.
+Spring AI supports logging the prompt content to help with debugging and troubleshooting.
[cols="6,3,1", stripes=even]
|====
| Property | Description | Default
-| `spring.ai.chat.client.observations.include-prompt` | Whether to include the chat client prompt content in the observations. | `false`
+| `spring.ai.chat.client.observations.log-prompt` | Whether to log the chat client prompt content. | `false`
|====
-WARNING: If you enable the inclusion of the chat client prompt content in the observations, there's a risk of exposing sensitive or private information. Please, be careful!
+WARNING: If you enable logging of the chat client prompt content, there's a risk of exposing sensitive or private information. Please, be careful!
=== Input Data (Deprecated)
-WARNING: The `spring.ai.chat.client.observations.include-input` property is deprecated, replaced by `spring.ai.chat.client.observations.include-prompt`. See xref:_prompt_content[Prompt Content].
+WARNING: The `spring.ai.chat.client.observations.include-input` property is deprecated, replaced by `spring.ai.chat.client.observations.log-prompt`. See xref:_prompt_content[Prompt Content].
The `ChatClient` input data is typically big and possibly containing sensitive information.
For those reasons, it is not exported by default.
-Spring AI supports exporting input data as span attributes/events across all tracing backends.
+Spring AI supports logging input data to help with debugging and troubleshooting.
[cols="6,3,1", stripes=even]
|====
@@ -147,35 +160,24 @@ IMPORTANT: The `gen_ai.client.token.usage` metrics measures number of input and
NOTE: For measuring user tokens, the previous table lists the values present in an observation trace.
Use the metric name `gen_ai.client.token.usage` that is provided by the `ChatModel`.
-.Events
-[cols="a,a", stripes=even]
-|===
-|Name | Description
-
-|`gen_ai.content.prompt` | Event including the content of the chat prompt. Optional.
-|`gen_ai.content.completion` | Event including the content of the chat completion. Optional.
-|===
=== Chat Prompt and Completion Data
The chat prompt and completion data is typically big and possibly containing sensitive information.
For those reasons, it is not exported by default.
-Spring AI supports exporting chat prompt and completion data as span events if you use an OpenTelemetry tracing backend,
-whereas data is exported as span attributes if you use an OpenZipkin tracing backend.
-
-Furthermore, Spring AI supports logging chat prompt and completion data, useful for troubleshooting scenarios.
+Spring AI supports logging chat prompt and completion data, useful for troubleshooting scenarios. When tracing is available, the logs will include trace information for better correlation.
[cols="6,3,1", stripes=even]
|====
| Property | Description | Default
-| `spring.ai.chat.observations.include-prompt` | Include the prompt content in observations. `true` or `false` | `false`
-| `spring.ai.chat.observations.include-completion` | Include the completion content in observations. `true` or `false` | `false`
+| `spring.ai.chat.observations.log-prompt` | Log the prompt content. `true` or `false` | `false`
+| `spring.ai.chat.observations.log-completion` | Log the completion content. `true` or `false` | `false`
| `spring.ai.chat.observations.include-error-logging` | Include error logging in observations. `true` or `false` | `false`
|====
-WARNING: If you enable the inclusion of the chat prompt and completion data in the observations, there's a risk of exposing sensitive or private information. Please, be careful!
+WARNING: If you enable logging of the chat prompt and completion data, there's a risk of exposing sensitive or private information. Please, be careful!
== EmbeddingModel
@@ -252,30 +254,22 @@ IMPORTANT: The `gen_ai.client.token.usage` metrics measures number of input and
NOTE: For measuring user tokens, the previous table lists the values present in an observation trace.
Use the metric name `gen_ai.client.token.usage` that is provided by the `ImageModel`.
-.Events
-[cols="a,a", stripes=even]
-|===
-|Name | Description
-
-|`gen_ai.content.prompt` | Event including the content of the image prompt. Optional.
-|===
=== Image Prompt Data
The image prompt data is typically big and possibly containing sensitive information.
For those reasons, it is not exported by default.
-Spring AI supports exporting image prompt data as span events if you use an OpenTelemetry tracing backend,
-whereas data is exported as span attributes if you use an OpenZipkin tracing backend.
+Spring AI supports logging image prompt data, useful for troubleshooting scenarios. When tracing is available, the logs will include trace information for better correlation.
[cols="6,3,1", stripes=even]
|===
| Property | Description | Default
-| `spring.ai.image.observations.include-prompt` | `true` or `false` | `false`
+| `spring.ai.image.observations.log-prompt` | Log the image prompt content. `true` or `false` | `false`
|===
-WARNING: If you enable the inclusion of the image prompt data in the observations, there's a risk of exposing sensitive or private information. Please, be careful!
+WARNING: If you enable logging of the image prompt data, there's a risk of exposing sensitive or private information. Please, be careful!
== Vector Stores
@@ -312,27 +306,19 @@ They measure the time spent on the `query`, `add` and `remove` operations and pr
|`db.vector.query.top_k` | The top-k most similar vectors returned by a query.
|===
-.Events
-[cols="a,a", stripes=even]
-|===
-|Name | Description
-
-|`db.vector.content.query.response` | Event including the vector search response data. Optional.
-|===
=== Response Data
The vector search response data is typically big and possibly containing sensitive information.
For those reasons, it is not exported by default.
-Spring AI supports exporting vector search response data as span events if you use an OpenTelemetry tracing backend,
-whereas data is exported as span attributes if you use an OpenZipkin tracing backend.
+Spring AI supports logging vector search response data, useful for troubleshooting scenarios. When tracing is available, the logs will include trace information for better correlation.
[cols="6,3,1", stripes=even]
|===
| Property | Description | Default
-| `spring.ai.vectorstore.observations.include-query-response` | `true` or `false` | `false`
+| `spring.ai.vectorstore.observations.log-query-response` | Log the vector store query response content. `true` or `false` | `false`
|===
-WARNING: If you enable the inclusion of the vector search response data in the observations, there's a risk of exposing sensitive or private information. Please, be careful!
+WARNING: If you enable logging of the vector search response data, there's a risk of exposing sensitive or private information. Please, be careful!
diff --git a/spring-ai-model/pom.xml b/spring-ai-model/pom.xml
index 167d12735..6f198decf 100644
--- a/spring-ai-model/pom.xml
+++ b/spring-ai-model/pom.xml
@@ -60,11 +60,10 @@
io.micrometer
- micrometer-tracing-bridge-otel
+ micrometer-tracing
true
-
org.springframework
spring-messaging
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java
deleted file mode 100644
index c68227bb5..000000000
--- a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright 2023-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.ObservationFilter;
-
-import org.springframework.ai.observation.tracing.TracingHelper;
-
-/**
- * An {@link ObservationFilter} to include the chat completion content in the observation.
- *
- * @author Thomas Vitale
- * @since 1.0.0
- */
-public class ChatModelCompletionObservationFilter implements ObservationFilter {
-
- @Override
- public Observation.Context map(Observation.Context context) {
- if (!(context instanceof ChatModelObservationContext chatModelObservationContext)) {
- return context;
- }
-
- var completions = ChatModelObservationContentProcessor.completion(chatModelObservationContext);
-
- chatModelObservationContext
- .addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.COMPLETION
- .withValue(TracingHelper.concatenateStrings(completions)));
-
- return chatModelObservationContext;
- }
-
-}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java
index 59612fa26..c3c4ab348 100644
--- a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java
+++ b/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -18,34 +18,47 @@ 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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.observation.ObservabilityHelper;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
-import org.springframework.ai.observation.conventions.AiObservationAttributes;
-import org.springframework.ai.observation.conventions.AiObservationEventNames;
-import org.springframework.ai.observation.tracing.TracingHelper;
+import java.util.List;
/**
- * Handler for including the chat completion content in the observation as a span event.
+ * Handler for emitting the chat completion content to logs.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
* @since 1.0.0
*/
public class ChatModelCompletionObservationHandler implements ObservationHandler {
+ private static final Logger logger = LoggerFactory.getLogger(ChatModelCompletionObservationHandler.class);
+
@Override
public void onStop(ChatModelObservationContext context) {
- TracingObservationHandler.TracingContext tracingContext = context
- .get(TracingObservationHandler.TracingContext.class);
- Span otelSpan = TracingHelper.extractOtelSpan(tracingContext);
+ logger.debug("Chat Model Completion:\n{}", ObservabilityHelper.concatenateStrings(completion(context)));
+ }
- if (otelSpan != null) {
- otelSpan.addEvent(AiObservationEventNames.CONTENT_COMPLETION.value(),
- Attributes.of(AttributeKey.stringArrayKey(AiObservationAttributes.COMPLETION.value()),
- ChatModelObservationContentProcessor.completion(context)));
+ private 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().getText())) {
+ return List.of();
+ }
+
+ return context.getResponse()
+ .getResults()
+ .stream()
+ .filter(generation -> generation.getOutput() != null
+ && StringUtils.hasText(generation.getOutput().getText()))
+ .map(generation -> generation.getOutput().getText())
+ .toList();
}
@Override
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java
deleted file mode 100644
index 59eaa6a24..000000000
--- a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright 2023-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 java.util.List;
-
-import org.springframework.ai.content.Content;
-import org.springframework.util.CollectionUtils;
-import org.springframework.util.StringUtils;
-
-/**
- * Utilities to process the prompt and completion content in observations for chat models.
- *
- * @author Thomas Vitale
- */
-public final class ChatModelObservationContentProcessor {
-
- private ChatModelObservationContentProcessor() {
- }
-
- public static List prompt(ChatModelObservationContext context) {
- if (CollectionUtils.isEmpty(context.getRequest().getInstructions())) {
- return List.of();
- }
-
- return context.getRequest().getInstructions().stream().map(Content::getText).toList();
- }
-
- public static List completion(ChatModelObservationContext context) {
- if (context == null || context.getResponse() == null || context.getResponse().getResults() == null
- || CollectionUtils.isEmpty(context.getResponse().getResults())) {
- return List.of();
- }
-
- if (!StringUtils.hasText(context.getResponse().getResult().getOutput().getText())) {
- return List.of();
- }
-
- return context.getResponse()
- .getResults()
- .stream()
- .filter(generation -> generation.getOutput() != null
- && StringUtils.hasText(generation.getOutput().getText()))
- .map(generation -> generation.getOutput().getText())
- .toList();
- }
-
-}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationDocumentation.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationDocumentation.java
index 82e792159..9f175608b 100644
--- a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationDocumentation.java
+++ b/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationDocumentation.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -20,9 +20,7 @@ import io.micrometer.common.docs.KeyName;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.docs.ObservationDocumentation;
-
import org.springframework.ai.observation.conventions.AiObservationAttributes;
-import org.springframework.ai.observation.conventions.AiObservationEventNames;
/**
* Documented conventions for chat model observations.
@@ -48,10 +46,6 @@ public enum ChatModelObservationDocumentation implements ObservationDocumentatio
return HighCardinalityKeyNames.values();
}
- @Override
- public Observation.Event[] getEvents() {
- return Events.values();
- }
};
/**
@@ -229,55 +223,6 @@ public enum ChatModelObservationDocumentation implements ObservationDocumentatio
public String asString() {
return AiObservationAttributes.USAGE_TOTAL_TOKENS.value();
}
- },
-
- // Content
-
- /**
- * The full prompt sent to the model.
- */
- PROMPT {
- @Override
- public String asString() {
- return AiObservationAttributes.PROMPT.value();
- }
- },
-
- /**
- * The full response received from the model.
- */
- COMPLETION {
- @Override
- public String asString() {
- return AiObservationAttributes.COMPLETION.value();
- }
- }
-
- }
-
- /**
- * Events for chat model operations.
- */
- public enum Events implements Observation.Event {
-
- /**
- * Content of the prompt sent to the model.
- */
- CONTENT_PROMPT {
- @Override
- public String getName() {
- return AiObservationEventNames.CONTENT_PROMPT.value();
- }
- },
-
- /**
- * Content of the completion returned by the model.
- */
- CONTENT_COMPLETION {
- @Override
- public String getName() {
- return AiObservationEventNames.CONTENT_COMPLETION.value();
- }
}
}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java
deleted file mode 100644
index e320c9ce8..000000000
--- a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright 2023-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.ObservationFilter;
-
-import org.springframework.ai.observation.tracing.TracingHelper;
-
-/**
- * An {@link ObservationFilter} to include the chat prompt content in the observation.
- *
- * @author Thomas Vitale
- * @since 1.0.0
- */
-public class ChatModelPromptContentObservationFilter implements ObservationFilter {
-
- @Override
- public Observation.Context map(Observation.Context context) {
- if (!(context instanceof ChatModelObservationContext chatModelObservationContext)) {
- return context;
- }
-
- var prompts = ChatModelObservationContentProcessor.prompt(chatModelObservationContext);
-
- chatModelObservationContext
- .addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT
- .withValue(TracingHelper.concatenateStrings(prompts)));
-
- return chatModelObservationContext;
- }
-
-}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java b/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java
index 001287c9a..067b75142 100644
--- a/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java
+++ b/spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -18,35 +18,36 @@ 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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.content.Content;
+import org.springframework.ai.observation.ObservabilityHelper;
+import org.springframework.util.CollectionUtils;
-import org.springframework.ai.observation.conventions.AiObservationAttributes;
-import org.springframework.ai.observation.conventions.AiObservationEventNames;
-import org.springframework.ai.observation.tracing.TracingHelper;
+import java.util.List;
/**
- * Handler for including the chat prompt content in the observation as a span event.
+ * Handler for emitting the chat prompt content to logs.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
* @since 1.0.0
*/
public class ChatModelPromptContentObservationHandler implements ObservationHandler {
+ private static final Logger logger = LoggerFactory.getLogger(ChatModelPromptContentObservationHandler.class);
+
@Override
public void onStop(ChatModelObservationContext context) {
- TracingObservationHandler.TracingContext tracingContext = context
- .get(TracingObservationHandler.TracingContext.class);
- Span otelSpan = TracingHelper.extractOtelSpan(tracingContext);
+ logger.debug("Chat Model Prompt Content:\n{}", ObservabilityHelper.concatenateStrings(prompt(context)));
+ }
- if (otelSpan != null) {
- otelSpan.addEvent(AiObservationEventNames.CONTENT_PROMPT.value(),
- Attributes.of(AttributeKey.stringArrayKey(AiObservationAttributes.PROMPT.value()),
- ChatModelObservationContentProcessor.prompt(context)));
+ private List prompt(ChatModelObservationContext context) {
+ if (CollectionUtils.isEmpty(context.getRequest().getInstructions())) {
+ return List.of();
}
+ return context.getRequest().getInstructions().stream().map(Content::getText).toList();
}
@Override
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelObservationDocumentation.java b/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelObservationDocumentation.java
index 2c1056c67..3dd7b13ba 100644
--- a/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelObservationDocumentation.java
+++ b/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelObservationDocumentation.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -20,9 +20,7 @@ import io.micrometer.common.docs.KeyName;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.docs.ObservationDocumentation;
-
import org.springframework.ai.observation.conventions.AiObservationAttributes;
-import org.springframework.ai.observation.conventions.AiObservationEventNames;
/**
* Documented conventions for image model observations.
@@ -48,10 +46,6 @@ public enum ImageModelObservationDocumentation implements ObservationDocumentati
return HighCardinalityKeyNames.values();
}
- @Override
- public Observation.Event[] getEvents() {
- return Events.values();
- }
};
/**
@@ -180,35 +174,6 @@ public enum ImageModelObservationDocumentation implements ObservationDocumentati
public String asString() {
return AiObservationAttributes.USAGE_TOTAL_TOKENS.value();
}
- },
-
- // Content
-
- /**
- * The full prompt sent to the model.
- */
- PROMPT {
- @Override
- public String asString() {
- return AiObservationAttributes.PROMPT.value();
- }
- }
-
- }
-
- /**
- * Events for image model operations.
- */
- public enum Events implements Observation.Event {
-
- /**
- * Content of the prompt sent to the model.
- */
- CONTENT_PROMPT {
- @Override
- public String getName() {
- return AiObservationEventNames.CONTENT_PROMPT.value();
- }
}
}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilter.java b/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilter.java
deleted file mode 100644
index 608edcaa0..000000000
--- a/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilter.java
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright 2023-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.image.observation;
-
-import java.util.StringJoiner;
-
-import io.micrometer.observation.Observation;
-import io.micrometer.observation.ObservationFilter;
-
-import org.springframework.util.CollectionUtils;
-
-/**
- * An {@link ObservationFilter} to include the image prompt content in the observation.
- *
- * @author Thomas Vitale
- * @since 1.0.0
- */
-public class ImageModelPromptContentObservationFilter implements ObservationFilter {
-
- @Override
- public Observation.Context map(Observation.Context context) {
- if (!(context instanceof ImageModelObservationContext imageModelObservationContext)) {
- return context;
- }
-
- if (CollectionUtils.isEmpty(imageModelObservationContext.getRequest().getInstructions())) {
- return imageModelObservationContext;
- }
-
- StringJoiner promptMessagesJoiner = new StringJoiner(", ", "[", "]");
- imageModelObservationContext.getRequest()
- .getInstructions()
- .forEach(message -> promptMessagesJoiner.add("\"" + message.getText() + "\""));
-
- imageModelObservationContext
- .addHighCardinalityKeyValue(ImageModelObservationDocumentation.HighCardinalityKeyNames.PROMPT
- .withValue(promptMessagesJoiner.toString()));
-
- return imageModelObservationContext;
- }
-
-}
diff --git a/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationHandler.java b/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationHandler.java
new file mode 100644
index 000000000..e27f2fb27
--- /dev/null
+++ b/spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationHandler.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2023-2025 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.image.observation;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.util.CollectionUtils;
+
+import java.util.StringJoiner;
+
+/**
+ * Handler for emitting image prompt content to logs.
+ *
+ * @author Thomas Vitale
+ * @author Jonatan Ivanov
+ * @since 1.0.0
+ */
+public class ImageModelPromptContentObservationHandler implements ObservationHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(ImageModelPromptContentObservationHandler.class);
+
+ @Override
+ public void onStop(ImageModelObservationContext context) {
+ if (!CollectionUtils.isEmpty(context.getRequest().getInstructions())) {
+ StringJoiner promptMessagesJoiner = new StringJoiner(", ", "[", "]");
+ context.getRequest()
+ .getInstructions()
+ .forEach(message -> promptMessagesJoiner.add("\"" + message.getText() + "\""));
+
+ logger.debug("Image Model Prompt Content:\n{}", promptMessagesJoiner);
+ }
+ }
+
+ @Override
+ public boolean supportsContext(Observation.Context context) {
+ return context instanceof ImageModelObservationContext;
+ }
+
+}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilterTests.java b/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilterTests.java
deleted file mode 100644
index 8471b9e73..000000000
--- a/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilterTests.java
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Copyright 2023-2025 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 java.util.List;
-
-import io.micrometer.common.KeyValue;
-import io.micrometer.observation.Observation;
-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.ChatOptions;
-import org.springframework.ai.chat.prompt.Prompt;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames;
-
-/**
- * Unit tests for {@link ChatModelCompletionObservationFilter}.
- *
- * @author Thomas Vitale
- */
-class ChatModelCompletionObservationFilterTests {
-
- private final ChatModelCompletionObservationFilter observationFilter = new ChatModelCompletionObservationFilter();
-
- @Test
- void whenNotSupportedObservationContextThenReturnOriginalContext() {
- var expectedContext = new Observation.Context();
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenEmptyResponseThenReturnOriginalContext() {
- var expectedContext = ChatModelObservationContext.builder()
- .prompt(generatePrompt(ChatOptions.builder().model("mistral").build()))
- .provider("superprovider")
- .build();
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenEmptyCompletionThenReturnOriginalContext() {
- var expectedContext = ChatModelObservationContext.builder()
- .prompt(generatePrompt(ChatOptions.builder().model("mistral").build()))
- .provider("superprovider")
- .build();
- expectedContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("")))));
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenCompletionWithTextThenAugmentContext() {
- var originalContext = ChatModelObservationContext.builder()
- .prompt(generatePrompt(ChatOptions.builder().model("mistral").build()))
- .provider("superprovider")
- .build();
- originalContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("say please")),
- new Generation(new AssistantMessage("seriously, say please")))));
- var augmentedContext = this.observationFilter.map(originalContext);
-
- assertThat(augmentedContext.getHighCardinalityKeyValues()).contains(KeyValue
- .of(HighCardinalityKeyNames.COMPLETION.asString(), "[\"say please\", \"seriously, say please\"]"));
- }
-
- private Prompt generatePrompt(ChatOptions chatOptions) {
- return new Prompt("supercalifragilisticexpialidocious", chatOptions);
- }
-
-}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java b/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java
index 768f2d8d3..4b204b7e5 100644
--- a/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java
+++ b/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java
@@ -16,24 +16,18 @@
package org.springframework.ai.chat.observation;
-import java.util.List;
-
-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 io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
-
+import org.junit.jupiter.api.extension.ExtendWith;
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.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
-import org.springframework.ai.observation.conventions.AiObservationAttributes;
-import org.springframework.ai.observation.conventions.AiObservationEventNames;
-import org.springframework.ai.observation.tracing.TracingHelper;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+
+import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -41,37 +35,72 @@ import static org.assertj.core.api.Assertions.assertThat;
* Unit tests for {@link ChatModelCompletionObservationHandler}.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
*/
+@ExtendWith(OutputCaptureExtension.class)
class ChatModelCompletionObservationHandlerTests {
+ private final ChatModelCompletionObservationHandler observationHandler = new ChatModelCompletionObservationHandler();
+
@Test
- void whenCompletionWithTextThenSpanEvent() {
- var observationContext = ChatModelObservationContext.builder()
- .prompt(new Prompt("supercalifragilisticexpialidocious",
- ChatOptions.builder().model("spoonful-of-sugar").build()))
- .provider("mary-poppins")
+ void whenNotSupportedObservationContextThenReturnFalse() {
+ var context = new Observation.Context();
+ assertThat(this.observationHandler.supportsContext(context)).isFalse();
+ }
+
+ @Test
+ void whenSupportedObservationContextThenReturnTrue() {
+ var context = ChatModelObservationContext.builder()
+ .prompt(generatePrompt(ChatOptions.builder().model("mistral").build()))
+ .provider("superprovider")
.build();
- observationContext.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("say please")),
+ assertThat(this.observationHandler.supportsContext(context)).isTrue();
+ }
+
+ @Test
+ void whenEmptyResponseThenOutputNothing(CapturedOutput output) {
+ var context = ChatModelObservationContext.builder()
+ .prompt(generatePrompt(ChatOptions.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Chat Model Completion:
+ []
+ """);
+ }
+
+ @Test
+ void whenEmptyCompletionThenOutputNothing(CapturedOutput output) {
+ var context = ChatModelObservationContext.builder()
+ .prompt(generatePrompt(ChatOptions.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ context.setResponse(new ChatResponse(List.of(new Generation(new AssistantMessage("")))));
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Chat Model Completion:
+ []
+ """);
+ }
+
+ @Test
+ void whenCompletionWithTextThenOutputIt(CapturedOutput output) {
+ var context = ChatModelObservationContext.builder()
+ .prompt(generatePrompt(ChatOptions.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ context.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);
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Chat Model Completion:
+ ["say please", "seriously, say please"]
+ """);
+ }
- new ChatModelCompletionObservationHandler().onStop(observationContext);
-
- var otelSpan = TracingHelper.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");
+ private Prompt generatePrompt(ChatOptions chatOptions) {
+ return new Prompt("supercalifragilisticexpialidocious", chatOptions);
}
}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilterTests.java b/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilterTests.java
deleted file mode 100644
index 638f1320b..000000000
--- a/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilterTests.java
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Copyright 2023-2025 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 java.util.List;
-
-import io.micrometer.common.KeyValue;
-import io.micrometer.observation.Observation;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.ai.chat.messages.SystemMessage;
-import org.springframework.ai.chat.messages.UserMessage;
-import org.springframework.ai.chat.prompt.ChatOptions;
-import org.springframework.ai.chat.prompt.Prompt;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames;
-
-/**
- * Unit tests for {@link ChatModelPromptContentObservationFilter}.
- *
- * @author Thomas Vitale
- */
-class ChatModelPromptContentObservationFilterTests {
-
- private final ChatModelPromptContentObservationFilter observationFilter = new ChatModelPromptContentObservationFilter();
-
- @Test
- void whenNotSupportedObservationContextThenReturnOriginalContext() {
- var expectedContext = new Observation.Context();
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenEmptyPromptThenReturnOriginalContext() {
- var expectedContext = ChatModelObservationContext.builder()
- .prompt(new Prompt(List.of(), ChatOptions.builder().model("mistral").build()))
- .provider("superprovider")
- .build();
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenPromptWithTextThenAugmentContext() {
- var originalContext = ChatModelObservationContext.builder()
- .prompt(new Prompt("supercalifragilisticexpialidocious", ChatOptions.builder().model("mistral").build()))
- .provider("superprovider")
- .build();
- var augmentedContext = this.observationFilter.map(originalContext);
-
- assertThat(augmentedContext.getHighCardinalityKeyValues()).contains(
- KeyValue.of(HighCardinalityKeyNames.PROMPT.asString(), "[\"supercalifragilisticexpialidocious\"]"));
- }
-
- @Test
- void whenPromptWithMessagesThenAugmentContext() {
- var originalContext = ChatModelObservationContext.builder()
- .prompt(new Prompt(
- List.of(new SystemMessage("you're a chimney sweep"),
- new UserMessage("supercalifragilisticexpialidocious")),
- ChatOptions.builder().model("mistral").build()))
- .provider("superprovider")
- .build();
- var augmentedContext = this.observationFilter.map(originalContext);
-
- assertThat(augmentedContext.getHighCardinalityKeyValues())
- .contains(KeyValue.of(HighCardinalityKeyNames.PROMPT.asString(),
- "[\"you're a chimney sweep\", \"supercalifragilisticexpialidocious\"]"));
- }
-
-}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java b/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java
index c76b52470..e48c5f729 100644
--- a/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java
+++ b/spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java
@@ -16,19 +16,17 @@
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 io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
-
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.ai.chat.messages.SystemMessage;
+import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
-import org.springframework.ai.observation.conventions.AiObservationAttributes;
-import org.springframework.ai.observation.conventions.AiObservationEventNames;
-import org.springframework.ai.observation.tracing.TracingHelper;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+
+import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -36,35 +34,68 @@ import static org.assertj.core.api.Assertions.assertThat;
* Unit tests for {@link ChatModelPromptContentObservationHandler}.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
*/
+@ExtendWith(OutputCaptureExtension.class)
class ChatModelPromptContentObservationHandlerTests {
+ private final ChatModelPromptContentObservationHandler observationHandler = new ChatModelPromptContentObservationHandler();
+
@Test
- void whenPromptWithTextThenSpanEvent() {
- var observationContext = ChatModelObservationContext.builder()
- .prompt(new Prompt("supercalifragilisticexpialidocious",
- ChatOptions.builder().model("spoonful-of-sugar").build()))
- .provider("mary-poppins")
+ void whenNotSupportedObservationContextThenReturnFalse() {
+ var context = new Observation.Context();
+ assertThat(observationHandler.supportsContext(context)).isFalse();
+ }
+
+ @Test
+ void whenSupportedObservationContextThenReturnTrue() {
+ var context = ChatModelObservationContext.builder()
+ .prompt(new Prompt(List.of(), ChatOptions.builder().model("mistral").build()))
+ .provider("superprovider")
.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);
+ assertThat(observationHandler.supportsContext(context)).isTrue();
+ }
- new ChatModelPromptContentObservationHandler().onStop(observationContext);
+ @Test
+ void whenEmptyPromptThenOutputNothing(CapturedOutput output) {
+ var context = ChatModelObservationContext.builder()
+ .prompt(new Prompt(List.of(), ChatOptions.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Chat Model Prompt Content:
+ []
+ """);
+ }
- var otelSpan = TracingHelper.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");
+ @Test
+ void whenPromptWithTextThenOutputIt(CapturedOutput output) {
+ var context = ChatModelObservationContext.builder()
+ .prompt(new Prompt("supercalifragilisticexpialidocious", ChatOptions.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Chat Model Prompt Content:
+ ["supercalifragilisticexpialidocious"]
+ """);
+ }
+
+ @Test
+ void whenPromptWithMessagesThenOutputIt(CapturedOutput output) {
+ var context = ChatModelObservationContext.builder()
+ .prompt(new Prompt(
+ List.of(new SystemMessage("you're a chimney sweep"),
+ new UserMessage("supercalifragilisticexpialidocious")),
+ ChatOptions.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Chat Model Prompt Content:
+ ["you're a chimney sweep", "supercalifragilisticexpialidocious"]
+ """);
}
}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilterTests.java b/spring-ai-model/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilterTests.java
deleted file mode 100644
index b14b2a053..000000000
--- a/spring-ai-model/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilterTests.java
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Copyright 2023-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.image.observation;
-
-import java.util.List;
-
-import io.micrometer.common.KeyValue;
-import io.micrometer.observation.Observation;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.ai.image.ImageMessage;
-import org.springframework.ai.image.ImageOptionsBuilder;
-import org.springframework.ai.image.ImagePrompt;
-import org.springframework.ai.observation.conventions.AiObservationAttributes;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Unit tests for {@link ImageModelPromptContentObservationFilter}.
- *
- * @author Thomas Vitale
- */
-class ImageModelPromptContentObservationFilterTests {
-
- private final ImageModelPromptContentObservationFilter observationFilter = new ImageModelPromptContentObservationFilter();
-
- @Test
- void whenNotSupportedObservationContextThenReturnOriginalContext() {
- var expectedContext = new Observation.Context();
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenEmptyPromptThenReturnOriginalContext() {
- var expectedContext = ImageModelObservationContext.builder()
- .imagePrompt(new ImagePrompt("", ImageOptionsBuilder.builder().model("mistral").build()))
- .provider("superprovider")
- .build();
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenPromptWithTextThenAugmentContext() {
- var originalContext = ImageModelObservationContext.builder()
- .imagePrompt(new ImagePrompt("supercalifragilisticexpialidocious",
- ImageOptionsBuilder.builder().model("mistral").build()))
- .provider("superprovider")
- .build();
- var augmentedContext = this.observationFilter.map(originalContext);
-
- assertThat(augmentedContext.getHighCardinalityKeyValues())
- .contains(KeyValue.of(AiObservationAttributes.PROMPT.value(), "[\"supercalifragilisticexpialidocious\"]"));
- }
-
- @Test
- void whenPromptWithMessagesThenAugmentContext() {
- var originalContext = ImageModelObservationContext.builder()
- .imagePrompt(new ImagePrompt(
- List.of(new ImageMessage("you're a chimney sweep"),
- new ImageMessage("supercalifragilisticexpialidocious")),
- ImageOptionsBuilder.builder().model("mistral").build()))
- .provider("superprovider")
- .build();
- var augmentedContext = this.observationFilter.map(originalContext);
-
- assertThat(augmentedContext.getHighCardinalityKeyValues())
- .contains(KeyValue.of(AiObservationAttributes.PROMPT.value(),
- "[\"you're a chimney sweep\", \"supercalifragilisticexpialidocious\"]"));
- }
-
-}
diff --git a/spring-ai-model/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationHandlerTests.java b/spring-ai-model/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationHandlerTests.java
new file mode 100644
index 000000000..529506012
--- /dev/null
+++ b/spring-ai-model/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationHandlerTests.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2023-2025 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.image.observation;
+
+import io.micrometer.observation.Observation;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.ai.image.ImageMessage;
+import org.springframework.ai.image.ImageOptionsBuilder;
+import org.springframework.ai.image.ImagePrompt;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link ImageModelPromptContentObservationHandler}.
+ *
+ * @author Thomas Vitale
+ * @author Jonatan Ivanov
+ */
+@ExtendWith(OutputCaptureExtension.class)
+class ImageModelPromptContentObservationHandlerTests {
+
+ private final ImageModelPromptContentObservationHandler observationHandler = new ImageModelPromptContentObservationHandler();
+
+ @Test
+ void whenNotSupportedObservationContextThenReturnFalse() {
+ var context = new Observation.Context();
+ assertThat(this.observationHandler.supportsContext(context)).isFalse();
+ }
+
+ @Test
+ void whenSupportedObservationContextThenReturnTrue() {
+ var context = ImageModelObservationContext.builder()
+ .imagePrompt(new ImagePrompt("", ImageOptionsBuilder.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ assertThat(this.observationHandler.supportsContext(context)).isTrue();
+ }
+
+ @Test
+ void whenEmptyPromptThenOutputNothing(CapturedOutput output) {
+ var context = ImageModelObservationContext.builder()
+ .imagePrompt(new ImagePrompt("", ImageOptionsBuilder.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Image Model Prompt Content:
+ [""]
+ """);
+ }
+
+ @Test
+ void whenPromptWithTextThenOutputIt(CapturedOutput output) {
+ var context = ImageModelObservationContext.builder()
+ .imagePrompt(new ImagePrompt("supercalifragilisticexpialidocious",
+ ImageOptionsBuilder.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Image Model Prompt Content:
+ ["supercalifragilisticexpialidocious"]
+ """);
+ }
+
+ @Test
+ void whenPromptWithMessagesThenOutputIt(CapturedOutput output) {
+ var context = ImageModelObservationContext.builder()
+ .imagePrompt(new ImagePrompt(
+ List.of(new ImageMessage("you're a chimney sweep"),
+ new ImageMessage("supercalifragilisticexpialidocious")),
+ ImageOptionsBuilder.builder().model("mistral").build()))
+ .provider("superprovider")
+ .build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Image Model Prompt Content:
+ ["you're a chimney sweep", "supercalifragilisticexpialidocious"]
+ """);
+ }
+
+}
diff --git a/spring-ai-model/src/test/resources/logback.xml b/spring-ai-model/src/test/resources/logback.xml
new file mode 100644
index 000000000..8bbea24da
--- /dev/null
+++ b/spring-ai-model/src/test/resources/logback.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n
+
+
+
+
+
+
+
+
diff --git a/spring-ai-vector-store/pom.xml b/spring-ai-vector-store/pom.xml
index 380d985d9..c64ad8dd0 100644
--- a/spring-ai-vector-store/pom.xml
+++ b/spring-ai-vector-store/pom.xml
@@ -47,12 +47,6 @@
${project.parent.version}
-
- io.micrometer
- micrometer-tracing-bridge-otel
- true
-
-
diff --git a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationDocumentation.java b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationDocumentation.java
index f351ca292..b564117ff 100644
--- a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationDocumentation.java
+++ b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationDocumentation.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -171,16 +171,6 @@ public enum VectorStoreObservationDocumentation implements ObservationDocumentat
}
},
- /**
- * Returned documents from a similarity search query.
- */
- DB_VECTOR_QUERY_RESPONSE_DOCUMENTS {
- @Override
- public String asString() {
- return "db.vector.query.response.documents";
- }
- },
-
/**
* Similarity threshold that accepts all search scores. A threshold value of 0.0
* means any similarity is accepted or disable the similarity threshold filtering.
diff --git a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationFilter.java b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationFilter.java
deleted file mode 100644
index a601acc3b..000000000
--- a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationFilter.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright 2023-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.vectorstore.observation;
-
-import io.micrometer.observation.Observation;
-import io.micrometer.observation.ObservationFilter;
-
-import org.springframework.ai.observation.tracing.TracingHelper;
-import org.springframework.util.CollectionUtils;
-
-/**
- * An {@link ObservationFilter} to include the Vector Store search response content in the
- * observation.
- *
- * @author Christian Tzolov
- * @author Thomas Vitale
- * @since 1.0.0
- */
-public class VectorStoreQueryResponseObservationFilter implements ObservationFilter {
-
- @Override
- public Observation.Context map(Observation.Context context) {
-
- if (!(context instanceof VectorStoreObservationContext observationContext)) {
- return context;
- }
-
- var documents = VectorStoreObservationContentProcessor.documents(observationContext);
-
- if (!CollectionUtils.isEmpty(documents)) {
- observationContext.addHighCardinalityKeyValue(
- VectorStoreObservationDocumentation.HighCardinalityKeyNames.DB_VECTOR_QUERY_RESPONSE_DOCUMENTS
- .withValue(TracingHelper.concatenateStrings(documents)));
- }
-
- return observationContext;
- }
-
-}
diff --git a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationHandler.java b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationHandler.java
index 9dbbefc8c..f33f32980 100644
--- a/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationHandler.java
+++ b/spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationHandler.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -18,37 +18,36 @@ package org.springframework.ai.vectorstore.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.VectorStoreObservationAttributes;
-import org.springframework.ai.observation.conventions.VectorStoreObservationEventNames;
-import org.springframework.ai.observation.tracing.TracingHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.document.Document;
+import org.springframework.ai.observation.ObservabilityHelper;
import org.springframework.util.CollectionUtils;
+import java.util.List;
+
/**
- * Handler for including the query response content in the observation as a span event.
+ * Handler for emitting the query response content to logs.
*
* @author Thomas Vitale
+ * @author Jonatan Ivanov
* @since 1.0.0
*/
public class VectorStoreQueryResponseObservationHandler implements ObservationHandler {
+ private static final Logger logger = LoggerFactory.getLogger(VectorStoreQueryResponseObservationHandler.class);
+
@Override
public void onStop(VectorStoreObservationContext context) {
- TracingObservationHandler.TracingContext tracingContext = context
- .get(TracingObservationHandler.TracingContext.class);
- Span otelSpan = TracingHelper.extractOtelSpan(tracingContext);
+ logger.debug("Vector Store Query Response:\n{}", ObservabilityHelper.concatenateStrings(documents(context)));
+ }
- var documents = VectorStoreObservationContentProcessor.documents(context);
-
- if (!CollectionUtils.isEmpty(documents) && otelSpan != null) {
- otelSpan.addEvent(VectorStoreObservationEventNames.CONTENT_QUERY_RESPONSE.value(), Attributes.of(
- AttributeKey.stringArrayKey(VectorStoreObservationAttributes.DB_VECTOR_QUERY_CONTENT.value()),
- documents));
+ private List documents(VectorStoreObservationContext context) {
+ if (CollectionUtils.isEmpty(context.getQueryResponse())) {
+ return List.of();
}
+
+ return context.getQueryResponse().stream().map(Document::getText).toList();
}
@Override
diff --git a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/DefaultVectorStoreObservationConventionTests.java b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/DefaultVectorStoreObservationConventionTests.java
index 1082f0633..89d36f6c5 100644
--- a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/DefaultVectorStoreObservationConventionTests.java
+++ b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/DefaultVectorStoreObservationConventionTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -16,18 +16,17 @@
package org.springframework.ai.vectorstore.observation;
-import java.util.List;
-
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
-
import org.springframework.ai.document.Document;
import org.springframework.ai.observation.conventions.SpringAiKind;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationDocumentation.HighCardinalityKeyNames;
import org.springframework.ai.vectorstore.observation.VectorStoreObservationDocumentation.LowCardinalityKeyNames;
+import java.util.List;
+
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -97,10 +96,6 @@ class DefaultVectorStoreObservationConventionTests {
.contains(KeyValue.of(LowCardinalityKeyNames.DB_OPERATION_NAME.asString(),
VectorStoreObservationContext.Operation.QUERY.value));
- // Optional, filter only added content
- assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext))
- .doesNotContain(KeyValue.of(HighCardinalityKeyNames.DB_VECTOR_QUERY_RESPONSE_DOCUMENTS, "[doc1,doc2]"));
-
assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains(
KeyValue.of(HighCardinalityKeyNames.DB_COLLECTION_NAME.asString(), "COLLECTION_NAME"),
KeyValue.of(HighCardinalityKeyNames.DB_VECTOR_DIMENSION_COUNT.asString(), "696"),
diff --git a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationFilterTests.java b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationFilterTests.java
deleted file mode 100644
index ba7a37e05..000000000
--- a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationFilterTests.java
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * Copyright 2023-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.vectorstore.observation;
-
-import java.util.List;
-
-import io.micrometer.common.KeyValue;
-import io.micrometer.observation.Observation;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.ai.document.Document;
-import org.springframework.ai.vectorstore.observation.VectorStoreObservationDocumentation.HighCardinalityKeyNames;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Unit tests for {@link VectorStoreQueryResponseObservationFilter}.
- *
- * @author Christian Tzolov
- * @author Thomas Vitale
- */
-class VectorStoreQueryResponseObservationFilterTests {
-
- private final VectorStoreQueryResponseObservationFilter observationFilter = new VectorStoreQueryResponseObservationFilter();
-
- @Test
- void whenNotSupportedObservationContextThenReturnOriginalContext() {
- var expectedContext = new Observation.Context();
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenEmptyQueryResponseThenReturnOriginalContext() {
- var expectedContext = VectorStoreObservationContext.builder("db", VectorStoreObservationContext.Operation.ADD)
- .build();
-
- var actualContext = this.observationFilter.map(expectedContext);
-
- assertThat(actualContext).isEqualTo(expectedContext);
- }
-
- @Test
- void whenNonEmptyQueryResponseThenAugmentContext() {
- var expectedContext = VectorStoreObservationContext.builder("db", VectorStoreObservationContext.Operation.ADD)
- .build();
-
- List queryResponseDocs = List.of(new Document("doc1"), new Document("doc2"));
-
- expectedContext.setQueryResponse(queryResponseDocs);
-
- var augmentedContext = this.observationFilter.map(expectedContext);
-
- assertThat(augmentedContext.getHighCardinalityKeyValues()).contains(KeyValue
- .of(HighCardinalityKeyNames.DB_VECTOR_QUERY_RESPONSE_DOCUMENTS.asString(), "[\"doc1\", \"doc2\"]"));
- }
-
-}
diff --git a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationHandlerTests.java b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationHandlerTests.java
index 8b79e7bf7..6ad8d5d19 100644
--- a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationHandlerTests.java
+++ b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationHandlerTests.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023-2024 the original author or authors.
+ * Copyright 2023-2025 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.
@@ -16,56 +16,60 @@
package org.springframework.ai.vectorstore.observation;
-import java.util.List;
-
-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 io.micrometer.observation.Observation;
import org.junit.jupiter.api.Test;
-
+import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.ai.document.Document;
-import org.springframework.ai.observation.conventions.VectorStoreObservationAttributes;
-import org.springframework.ai.observation.conventions.VectorStoreObservationEventNames;
-import org.springframework.ai.observation.tracing.TracingHelper;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+
+import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link VectorStoreQueryResponseObservationHandler}.
*
+ * @author Christian Tzolov
* @author Thomas Vitale
+ * @author Jonatan Ivanov
*/
+@ExtendWith(OutputCaptureExtension.class)
class VectorStoreQueryResponseObservationHandlerTests {
+ private final VectorStoreQueryResponseObservationHandler observationHandler = new VectorStoreQueryResponseObservationHandler();
+
@Test
- void whenCompletionWithTextThenSpanEvent() {
- var observationContext = VectorStoreObservationContext
- .builder("db", VectorStoreObservationContext.Operation.ADD)
- .queryResponse(List.of(new Document("hello"), new Document("other-side")))
- .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);
+ void whenNotSupportedObservationContextThenReturnFalse() {
+ var context = new Observation.Context();
+ assertThat(this.observationHandler.supportsContext(context)).isFalse();
+ }
- new VectorStoreQueryResponseObservationHandler().onStop(observationContext);
+ @Test
+ void whenSupportedObservationContextThenReturnTrue() {
+ var context = VectorStoreObservationContext.builder("db", VectorStoreObservationContext.Operation.ADD).build();
+ assertThat(this.observationHandler.supportsContext(context)).isTrue();
+ }
- var otelSpan = TracingHelper.extractOtelSpan(tracingContext);
- assertThat(otelSpan).isNotNull();
- var spanData = ((ReadableSpan) otelSpan).toSpanData();
- assertThat(spanData.getEvents().size()).isEqualTo(1);
- assertThat(spanData.getEvents().get(0).getName())
- .isEqualTo(VectorStoreObservationEventNames.CONTENT_QUERY_RESPONSE.value());
- assertThat(spanData.getEvents()
- .get(0)
- .getAttributes()
- .get(AttributeKey.stringArrayKey(VectorStoreObservationAttributes.DB_VECTOR_QUERY_CONTENT.value())))
- .containsOnly("hello", "other-side");
+ @Test
+ void whenEmptyQueryResponseThenOutputNothing(CapturedOutput output) {
+ var context = VectorStoreObservationContext.builder("db", VectorStoreObservationContext.Operation.ADD).build();
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Vector Store Query Response:
+ []
+ """);
+ }
+
+ @Test
+ void whenNonEmptyQueryResponseThenOutputIt(CapturedOutput output) {
+ var context = VectorStoreObservationContext.builder("db", VectorStoreObservationContext.Operation.ADD).build();
+ context.setQueryResponse(List.of(new Document("doc1"), new Document("doc2")));
+ observationHandler.onStop(context);
+ assertThat(output).contains("""
+ Vector Store Query Response:
+ ["doc1", "doc2"]
+ """);
}
}
diff --git a/spring-ai-vector-store/src/test/resources/logback.xml b/spring-ai-vector-store/src/test/resources/logback.xml
new file mode 100644
index 000000000..8bbea24da
--- /dev/null
+++ b/spring-ai-vector-store/src/test/resources/logback.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n
+
+
+
+
+
+
+
+