From ca843e85887aa1da6300c77550c379c103500897 Mon Sep 17 00:00:00 2001 From: Jonatan Ivanov Date: Mon, 5 May 2025 18:18:57 -0700 Subject: [PATCH] feat(observability): refactor content observation to use logging instead of tracing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This removes the possible large amount of data that was attached to spans and it logs the data out. This change also removes the direct dependency on the OTel SDK. approach for content in Spring AI. Refactors the observability approach for content in Spring AI: - Replace content observation filters with logging handlers - Rename configuration properties to better reflect their purpose: - `include-prompt` → `log-prompt` - `include-completion` → `log-completion` - `include-query-response` → `log-query-response` - Add TracingAwareLoggingObservationHandler for trace-aware logging - Replace micrometer-tracing-bridge-otel with micrometer-tracing - Remove event-based tracing in favor of direct logging - Update documentation to reflect these changes (add breaking-changes section) - Rename includePrompt to logPrompt in observation properties. Updated in ChatClientBuilderProperties, ChatObservationProperties, and ImageObservationProperties. Signed-off-by: Christian Tzolov --- .../pom.xml | 6 + .../ChatClientAutoConfiguration.java | 59 ++++++-- .../ChatClientBuilderProperties.java | 12 +- ...ientObservationAutoConfigurationTests.java | 17 ++- .../pom.xml | 2 +- .../ChatObservationAutoConfiguration.java | 128 +++++++--------- .../ChatObservationProperties.java | 13 +- ...ChatObservationAutoConfigurationTests.java | 36 ++--- .../pom.xml | 6 + .../ImageObservationAutoConfiguration.java | 56 +++++-- .../ImageObservationProperties.java | 13 +- ...mageObservationAutoConfigurationTests.java | 18 ++- .../pom.xml | 2 +- ...ctorStoreObservationAutoConfiguration.java | 65 ++++---- ...toreObservationAutoConfigurationTests.java | 26 ++-- .../ChatClientObservationDocumentation.java | 17 +-- ...lientPromptContentObservationHandler.java} | 34 ++--- ...efaultChatClientObservationConvention.java | 11 +- ...ntPromptContentObservationFilterTests.java | 88 ----------- ...tPromptContentObservationHandlerTests.java | 97 ++++++++++++ spring-ai-commons/pom.xml | 8 +- .../ai/observation/ObservabilityHelper.java | 27 ++-- ...TracingAwareLoggingObservationHandler.java | 102 +++++++++++++ .../conventions/AiObservationAttributes.java | 15 +- .../conventions/AiObservationEventNames.java | 59 -------- .../VectorStoreObservationEventNames.java | 50 ------ .../ai/observation/tracing/TracingHelper.java | 81 ---------- .../observation/ObservabilityHelperTests.java | 55 +++++++ .../tracing/TracingHelperTests.java | 143 ------------------ .../ROOT/pages/observability/index.adoc | 70 ++++----- spring-ai-model/pom.xml | 3 +- .../ChatModelCompletionObservationFilter.java | 47 ------ ...ChatModelCompletionObservationHandler.java | 45 ++++-- .../ChatModelObservationContentProcessor.java | 62 -------- .../ChatModelObservationDocumentation.java | 57 +------ ...atModelPromptContentObservationFilter.java | 47 ------ ...tModelPromptContentObservationHandler.java | 33 ++-- .../ImageModelObservationDocumentation.java | 37 +---- ...geModelPromptContentObservationFilter.java | 56 ------- ...eModelPromptContentObservationHandler.java | 55 +++++++ ...ModelCompletionObservationFilterTests.java | 92 ----------- ...odelCompletionObservationHandlerTests.java | 101 ++++++++----- ...elPromptContentObservationFilterTests.java | 89 ----------- ...lPromptContentObservationHandlerTests.java | 95 ++++++++---- ...elPromptContentObservationFilterTests.java | 89 ----------- ...lPromptContentObservationHandlerTests.java | 101 +++++++++++++ .../src/test/resources/logback.xml | 29 ++++ spring-ai-vector-store/pom.xml | 6 - .../VectorStoreObservationDocumentation.java | 12 +- ...orStoreQueryResponseObservationFilter.java | 53 ------- ...rStoreQueryResponseObservationHandler.java | 37 +++-- ...VectorStoreObservationConventionTests.java | 11 +- ...reQueryResponseObservationFilterTests.java | 73 --------- ...eQueryResponseObservationHandlerTests.java | 76 +++++----- .../src/test/resources/logback.xml | 29 ++++ 55 files changed, 1032 insertions(+), 1619 deletions(-) rename spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/{ChatClientPromptContentObservationFilter.java => ChatClientPromptContentObservationHandler.java} (60%) delete mode 100644 spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationFilterTests.java create mode 100644 spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/observation/ChatClientPromptContentObservationHandlerTests.java rename spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreObservationContentProcessor.java => spring-ai-commons/src/main/java/org/springframework/ai/observation/ObservabilityHelper.java (50%) create mode 100644 spring-ai-commons/src/main/java/org/springframework/ai/observation/TracingAwareLoggingObservationHandler.java delete mode 100644 spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiObservationEventNames.java delete mode 100644 spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/VectorStoreObservationEventNames.java delete mode 100644 spring-ai-commons/src/main/java/org/springframework/ai/observation/tracing/TracingHelper.java create mode 100644 spring-ai-commons/src/test/java/org/springframework/ai/observation/ObservabilityHelperTests.java delete mode 100644 spring-ai-commons/src/test/java/org/springframework/ai/observation/tracing/TracingHelperTests.java delete mode 100644 spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java delete mode 100644 spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java delete mode 100644 spring-ai-model/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java delete mode 100644 spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilter.java create mode 100644 spring-ai-model/src/main/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationHandler.java delete mode 100644 spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilterTests.java delete mode 100644 spring-ai-model/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilterTests.java delete mode 100644 spring-ai-model/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationFilterTests.java create mode 100644 spring-ai-model/src/test/java/org/springframework/ai/image/observation/ImageModelPromptContentObservationHandlerTests.java create mode 100644 spring-ai-model/src/test/resources/logback.xml delete mode 100644 spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationFilter.java delete mode 100644 spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/observation/VectorStoreQueryResponseObservationFilterTests.java create mode 100644 spring-ai-vector-store/src/test/resources/logback.xml 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 + + + + + + + +