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 + + + + + + + +