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 d85ff3f022a..3ef9454d351 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 @@ -18,12 +18,20 @@ import io.micrometer.tracing.Tracer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.ai.chat.client.observation.ChatClientObservationContext; import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationHandler; +import org.springframework.ai.observation.TracingAwareLoggingObservationHandler; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Unit tests for {@link ChatClientAutoConfiguration} observability support. @@ -32,22 +40,127 @@ * @author Thomas Vitale * @author Jonatan Ivanov */ +@ExtendWith(OutputCaptureExtension.class) class ChatClientObservationAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(ChatClientAutoConfiguration.class)); @Test - void promptContentHandlerDefault() { - this.contextRunner - .run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationHandler.class)); + void promptContentHandlerNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void promptContentHandlerWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); } @Test - void promptContentHandlerEnabled() { + void promptContentHandlerEnabledNoTracer(CapturedOutput output) { this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) .withPropertyValues("spring.ai.chat.client.observations.log-prompt=true") - .run(context -> assertThat(context).hasSingleBean(ChatClientPromptContentObservationHandler.class)); + .run(context -> assertThat(context).hasSingleBean(ChatClientPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out the ChatClient prompt content with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Test + void promptContentHandlerEnabledWithTracer(CapturedOutput output) { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.chat.client.observations.log-prompt=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationHandler.class) + .hasSingleBean(TracingAwareLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out the ChatClient prompt content with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Test + void promptContentHandlerDisabledNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.chat.client.observations.log-prompt=false") + .run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void promptContentHandlerDisabledWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.chat.client.observations.log-prompt=false") + .run(context -> assertThat(context).doesNotHaveBean(ChatClientPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void customChatClientPromptContentObservationHandlerNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withUserConfiguration(CustomChatClientPromptContentObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.chat.client.observations.log-prompt=true") + .run(context -> assertThat(context).hasSingleBean(ChatClientPromptContentObservationHandler.class) + .hasBean("customChatClientPromptContentObservationHandler") + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void customChatClientPromptContentObservationHandlerWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomChatClientPromptContentObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.chat.client.observations.log-prompt=true") + .run(context -> assertThat(context).hasSingleBean(ChatClientPromptContentObservationHandler.class) + .hasBean("customChatClientPromptContentObservationHandler") + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void customTracingAwareLoggingObservationHandler() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomTracingAwareLoggingObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.chat.client.observations.log-prompt=true") + .run(context -> { + assertThat(context).hasSingleBean(TracingAwareLoggingObservationHandler.class) + .hasBean("chatClientPromptContentObservationHandler") + .doesNotHaveBean(ChatClientPromptContentObservationHandler.class); + assertThat(context.getBean(TracingAwareLoggingObservationHandler.class)) + .isSameAs(CustomTracingAwareLoggingObservationHandlerConfiguration.handlerInstance); + }); + } + + @Configuration(proxyBeanMethods = false) + static class TracerConfiguration { + + @Bean + Tracer tracer() { + return mock(Tracer.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomChatClientPromptContentObservationHandlerConfiguration { + + @Bean + ChatClientPromptContentObservationHandler customChatClientPromptContentObservationHandler() { + return new ChatClientPromptContentObservationHandler(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomTracingAwareLoggingObservationHandlerConfiguration { + + static TracingAwareLoggingObservationHandler handlerInstance = new TracingAwareLoggingObservationHandler<>( + new ChatClientPromptContentObservationHandler(), null); + + @Bean + TracingAwareLoggingObservationHandler chatClientPromptContentObservationHandler() { + return handlerInstance; + } + } } 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 145c51e9c2f..1db3a52030a 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 @@ -19,15 +19,26 @@ import io.micrometer.core.instrument.composite.CompositeMeterRegistry; import io.micrometer.tracing.Tracer; import org.junit.jupiter.api.Test; - +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.ai.chat.client.observation.ChatClientObservationContext; 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.ChatModelPromptContentObservationHandler; +import org.springframework.ai.model.observation.ErrorLoggingObservationHandler; +import org.springframework.ai.observation.TracingAwareLoggingObservationHandler; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Unit tests for {@link ChatObservationAutoConfiguration}. @@ -35,6 +46,7 @@ * @author Thomas Vitale * @author Jonatan Ivanov */ +@ExtendWith(OutputCaptureExtension.class) class ChatObservationAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() @@ -52,41 +64,308 @@ void meterObservationHandlerDisabled() { } @Test - void promptHandlerDefault() { - this.contextRunner - .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class)); + void handlersNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void handlersWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void promptContentHandlerEnabledNoTracer(CapturedOutput output) { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.chat.observations.log-prompt=true") + .run(context -> assertThat(context).hasSingleBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out the prompt content with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Test + void promptContentHandlerEnabledWithTracer(CapturedOutput output) { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.log-prompt=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .hasSingleBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out the prompt content with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Test + void promptContentHandlerDisabledNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.chat.observations.log-prompt=false") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void promptContentHandlerDisabledWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.log-prompt=false") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void completionHandlerEnabledNoTracer(CapturedOutput output) { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.chat.observations.log-completion=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .hasSingleBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out the completion content with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Test + void completionHandlerEnabledWithTracer(CapturedOutput output) { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.log-completion=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .hasSingleBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out the completion content with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Test + void completionHandlerDisabledNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.chat.observations.log-completion=false") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void completionHandlerDisabledWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.log-completion=false") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void errorLoggingHandlerEnabledNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.chat.observations.include-error-logging=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void errorLoggingHandlerEnabledWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.include-error-logging=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .hasSingleBean(ErrorLoggingObservationHandler.class)); } @Test - void promptHandlerEnabled() { + void errorLoggingHandlerDisabledNoTracer() { this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.chat.observations.include-error-logging=false") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void errorLoggingHandlerDisabledWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.include-error-logging=false") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void customChatModelPromptContentObservationHandlerNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withUserConfiguration(CustomChatModelPromptContentObservationHandlerConfiguration.class) .withPropertyValues("spring.ai.chat.observations.log-prompt=true") - .run(context -> assertThat(context).hasSingleBean(ChatModelPromptContentObservationHandler.class)); + .run(context -> assertThat(context).hasSingleBean(ChatModelPromptContentObservationHandler.class) + .hasBean("customChatModelPromptContentObservationHandler") + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); } @Test - void promptHandlerDisabled() { - this.contextRunner.withPropertyValues("spring.ai.chat.observations.log-prompt=false") - .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class)); + void customChatModelPromptContentObservationHandlerWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomChatModelPromptContentObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.log-prompt=true") + .run(context -> assertThat(context).hasSingleBean(ChatModelPromptContentObservationHandler.class) + .hasBean("customChatModelPromptContentObservationHandler") + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); } @Test - void completionHandlerDefault() { - this.contextRunner - .run(context -> assertThat(context).doesNotHaveBean(ChatModelCompletionObservationHandler.class)); + void customTracingAwareLoggingObservationHandlerForChatModelPromptContent() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration( + CustomTracingAwareLoggingObservationHandlerForChatModelPromptContentConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.log-prompt=true") + .run(context -> { + assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .hasSingleBean(TracingAwareLoggingObservationHandler.class) + .hasBean("chatModelPromptContentObservationHandler") + .doesNotHaveBean(ErrorLoggingObservationHandler.class); + assertThat(context.getBean(TracingAwareLoggingObservationHandler.class)).isSameAs( + CustomTracingAwareLoggingObservationHandlerForChatModelPromptContentConfiguration.handlerInstance); + }); } @Test - void completionHandlerEnabled() { + void customChatModelCompletionObservationHandlerNoTracer() { this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withUserConfiguration(CustomChatModelCompletionObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.log-completion=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .hasSingleBean(ChatModelCompletionObservationHandler.class) + .hasBean("customChatModelCompletionObservationHandler") + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); + } + + @Test + void customChatModelCompletionObservationHandlerWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomChatModelCompletionObservationHandlerConfiguration.class) .withPropertyValues("spring.ai.chat.observations.log-completion=true") - .run(context -> assertThat(context).hasSingleBean(ChatModelCompletionObservationHandler.class)); + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .hasSingleBean(ChatModelCompletionObservationHandler.class) + .hasBean("customChatModelCompletionObservationHandler") + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .doesNotHaveBean(ErrorLoggingObservationHandler.class)); } @Test - void completionHandlerDisabled() { - this.contextRunner.withPropertyValues("spring.ai.chat.observations.log-completion=false") - .run(context -> assertThat(context).doesNotHaveBean(ChatModelCompletionObservationHandler.class)); + void customTracingAwareLoggingObservationHandlerForChatModelCompletion() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomTracingAwareLoggingObservationHandlerForChatModelCompletionConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.log-completion=true") + .run(context -> { + assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .hasSingleBean(TracingAwareLoggingObservationHandler.class) + .hasBean("chatModelCompletionObservationHandler") + .doesNotHaveBean(ErrorLoggingObservationHandler.class); + assertThat(context.getBean(TracingAwareLoggingObservationHandler.class)).isSameAs( + CustomTracingAwareLoggingObservationHandlerForChatModelCompletionConfiguration.handlerInstance); + }); + } + + @Test + void customErrorLoggingObservationHandler() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomErrorLoggingObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.chat.observations.include-error-logging=true") + .run(context -> assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class) + .doesNotHaveBean(ChatModelCompletionObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class) + .hasSingleBean(ErrorLoggingObservationHandler.class) + .hasBean("customErrorLoggingObservationHandler")); + } + + @Configuration(proxyBeanMethods = false) + static class TracerConfiguration { + + @Bean + Tracer tracer() { + return mock(Tracer.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomChatModelPromptContentObservationHandlerConfiguration { + + @Bean + ChatModelPromptContentObservationHandler customChatModelPromptContentObservationHandler() { + return new ChatModelPromptContentObservationHandler(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomTracingAwareLoggingObservationHandlerForChatModelPromptContentConfiguration { + + static TracingAwareLoggingObservationHandler handlerInstance = new TracingAwareLoggingObservationHandler<>( + new ChatModelPromptContentObservationHandler(), null); + + @Bean + TracingAwareLoggingObservationHandler chatModelPromptContentObservationHandler() { + return handlerInstance; + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomChatModelCompletionObservationHandlerConfiguration { + + @Bean + ChatModelCompletionObservationHandler customChatModelCompletionObservationHandler() { + return new ChatModelCompletionObservationHandler(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomTracingAwareLoggingObservationHandlerForChatModelCompletionConfiguration { + + static TracingAwareLoggingObservationHandler handlerInstance = new TracingAwareLoggingObservationHandler<>( + new ChatModelCompletionObservationHandler(), null); + + @Bean + TracingAwareLoggingObservationHandler chatModelCompletionObservationHandler() { + return handlerInstance; + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomErrorLoggingObservationHandlerConfiguration { + + @Bean + ErrorLoggingObservationHandler customErrorLoggingObservationHandler(Tracer tracer) { + return new ErrorLoggingObservationHandler(tracer, List.of(ChatClientObservationContext.class)); + } + } } 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 b75aa050f24..3fa18e799b5 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 @@ -18,13 +18,20 @@ import io.micrometer.tracing.Tracer; import org.junit.jupiter.api.Test; - +import org.junit.jupiter.api.extension.ExtendWith; +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.AutoConfigurations; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Unit tests for {@link ImageObservationAutoConfiguration}. @@ -32,22 +39,129 @@ * @author Thomas Vitale * @author Jonatan Ivanov */ +@ExtendWith(OutputCaptureExtension.class) class ImageObservationAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(ImageObservationAutoConfiguration.class)); @Test - void promptHandlerDefault() { - this.contextRunner - .run(context -> assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationHandler.class)); + void imageModelPromptContentHandlerNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .run(context -> assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void imageModelPromptContentHandlerWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .run(context -> assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void imageModelPromptContentHandlerEnabledNoTracer(CapturedOutput output) { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.image.observations.log-prompt=true") + .run(context -> assertThat(context).hasSingleBean(ImageModelPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out the image prompt content with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Test + void imageModelPromptContentHandlerEnabledWithTracer(CapturedOutput output) { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.image.observations.log-prompt=true") + .run(context -> assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationHandler.class) + .hasSingleBean(TracingAwareLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out the image prompt content with the risk of exposing sensitive or private information. Please, be careful!"); } @Test - void promptHandlerEnabled() { + void imageModelPromptContentHandlerDisabledNoTracer() { this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.image.observations.log-prompt=false") + .run(context -> assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void imageModelPromptContentHandlerDisabledWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.image.observations.log-prompt=false") + .run(context -> assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void customChatClientPromptContentObservationHandlerNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withUserConfiguration(CustomImageModelPromptContentObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.image.observations.log-prompt=true") + .run(context -> assertThat(context).hasSingleBean(ImageModelPromptContentObservationHandler.class) + .hasBean("customImageModelPromptContentObservationHandler") + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + + } + + @Test + void customChatClientPromptContentObservationHandlerWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomImageModelPromptContentObservationHandlerConfiguration.class) .withPropertyValues("spring.ai.image.observations.log-prompt=true") - .run(context -> assertThat(context).hasSingleBean(ImageModelPromptContentObservationHandler.class)); + .run(context -> assertThat(context).hasSingleBean(ImageModelPromptContentObservationHandler.class) + .hasBean("customImageModelPromptContentObservationHandler") + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void customTracingAwareLoggingObservationHandler() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomTracingAwareLoggingObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.image.observations.log-prompt=true") + .run(context -> { + assertThat(context).doesNotHaveBean(ImageModelPromptContentObservationHandler.class) + .hasSingleBean(TracingAwareLoggingObservationHandler.class) + .hasBean("imageModelPromptContentObservationHandler"); + assertThat(context.getBean(TracingAwareLoggingObservationHandler.class)) + .isSameAs(CustomTracingAwareLoggingObservationHandlerConfiguration.handlerInstance); + }); + + } + + @Configuration(proxyBeanMethods = false) + static class TracerConfiguration { + + @Bean + Tracer tracer() { + return mock(Tracer.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomImageModelPromptContentObservationHandlerConfiguration { + + @Bean + ImageModelPromptContentObservationHandler customImageModelPromptContentObservationHandler() { + return new ImageModelPromptContentObservationHandler(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomTracingAwareLoggingObservationHandlerConfiguration { + + static TracingAwareLoggingObservationHandler handlerInstance = new TracingAwareLoggingObservationHandler<>( + new ImageModelPromptContentObservationHandler(), null); + + @Bean + TracingAwareLoggingObservationHandler imageModelPromptContentObservationHandler() { + return handlerInstance; + } + } } 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 482edb1776e..dcdab68371b 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 @@ -18,13 +18,20 @@ import io.micrometer.tracing.Tracer; import org.junit.jupiter.api.Test; - +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.ai.observation.TracingAwareLoggingObservationHandler; +import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext; 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 org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Unit tests for {@link VectorStoreObservationAutoConfiguration}. @@ -32,28 +39,127 @@ * @author Christian Tzolov * @author Jonatan Ivanov */ +@ExtendWith(OutputCaptureExtension.class) class VectorStoreObservationAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(VectorStoreObservationAutoConfiguration.class)); @Test - void queryResponseHandlerDefault() { - this.contextRunner - .run(context -> assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationHandler.class)); + void queryResponseHandlerNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .run(context -> assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void queryResponseHandlerWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .run(context -> assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); } @Test - void queryResponseHandlerEnabled() { + void queryResponseHandlerEnabledNoTracer(CapturedOutput output) { this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) .withPropertyValues("spring.ai.vectorstore.observations.log-query-response=true") - .run(context -> assertThat(context).hasSingleBean(VectorStoreQueryResponseObservationHandler.class)); + .run(context -> assertThat(context).hasSingleBean(VectorStoreQueryResponseObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out of the query response content with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Test + void queryResponseHandlerEnabledWithTracer(CapturedOutput output) { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.vectorstore.observations.log-query-response=true") + .run(context -> assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationHandler.class) + .hasSingleBean(TracingAwareLoggingObservationHandler.class)); + assertThat(output).contains( + "You have enabled logging out of the query response content with the risk of exposing sensitive or private information. Please, be careful!"); + } + + @Test + void queryResponseHandlerDisabledNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withPropertyValues("spring.ai.vectorstore.observations.log-query-response=false") + .run(context -> assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void queryResponseHandlerDisabledWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withPropertyValues("spring.ai.vectorstore.observations.log-query-response=false") + .run(context -> assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationHandler.class) + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); } @Test - void queryResponseHandlerDisabled() { - this.contextRunner.withPropertyValues("spring.ai.vectorstore.observations.log-query-response=false") - .run(context -> assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationHandler.class)); + void customQueryResponseHandlerNoTracer() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Tracer.class)) + .withUserConfiguration(CustomVectorStoreQueryResponseObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.vectorstore.observations.log-query-response=true") + .run(context -> assertThat(context).hasSingleBean(VectorStoreQueryResponseObservationHandler.class) + .hasBean("customVectorStoreQueryResponseObservationHandler") + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void customQueryResponseHandlerWithTracer() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomVectorStoreQueryResponseObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.vectorstore.observations.log-query-response=true") + .run(context -> assertThat(context).hasSingleBean(VectorStoreQueryResponseObservationHandler.class) + .hasBean("customVectorStoreQueryResponseObservationHandler") + .doesNotHaveBean(TracingAwareLoggingObservationHandler.class)); + } + + @Test + void customTracingAwareLoggingObservationHandler() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withUserConfiguration(CustomTracingAwareLoggingObservationHandlerConfiguration.class) + .withPropertyValues("spring.ai.vectorstore.observations.log-query-response=true") + .run(context -> { + assertThat(context).doesNotHaveBean(VectorStoreQueryResponseObservationHandler.class) + .hasSingleBean(TracingAwareLoggingObservationHandler.class) + .hasBean("vectorStoreQueryResponseObservationHandler"); + assertThat(context.getBean(TracingAwareLoggingObservationHandler.class)) + .isSameAs(CustomTracingAwareLoggingObservationHandlerConfiguration.handlerInstance); + }); + } + + @Configuration(proxyBeanMethods = false) + static class TracerConfiguration { + + @Bean + Tracer tracer() { + return mock(Tracer.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomVectorStoreQueryResponseObservationHandlerConfiguration { + + @Bean + VectorStoreQueryResponseObservationHandler customVectorStoreQueryResponseObservationHandler() { + return new VectorStoreQueryResponseObservationHandler(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomTracingAwareLoggingObservationHandlerConfiguration { + + static TracingAwareLoggingObservationHandler handlerInstance = new TracingAwareLoggingObservationHandler<>( + new VectorStoreQueryResponseObservationHandler(), null); + + @Bean + TracingAwareLoggingObservationHandler vectorStoreQueryResponseObservationHandler() { + return handlerInstance; + } + } } diff --git a/spring-ai-commons/src/test/java/org/springframework/ai/observation/TracingAwareLoggingObservationHandlerTests.java b/spring-ai-commons/src/test/java/org/springframework/ai/observation/TracingAwareLoggingObservationHandlerTests.java new file mode 100644 index 00000000000..f3c433384db --- /dev/null +++ b/spring-ai-commons/src/test/java/org/springframework/ai/observation/TracingAwareLoggingObservationHandlerTests.java @@ -0,0 +1,105 @@ +/* + * 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.TraceContext; +import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.handler.TracingObservationHandler; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.*; + +/** + * Tests for {@link TracingAwareLoggingObservationHandler}. + * + * @author Jonatan Ivanov + */ +@ExtendWith(MockitoExtension.class) +class TracingAwareLoggingObservationHandlerTests { + + @Mock + private ObservationHandler delegate; + + @Mock + private Tracer tracer; + + @InjectMocks + private TracingAwareLoggingObservationHandler handler; + + @Test + void callsShouldBeDelegated() { + Observation.Context context = new Observation.Context(); + context.put(TracingObservationHandler.TracingContext.class, new TracingObservationHandler.TracingContext()); + + handler.onStart(context); + verify(delegate).onStart(context); + + handler.onError(context); + verify(delegate).onError(context); + + Observation.Event event = Observation.Event.of("test"); + handler.onEvent(event, context); + verify(delegate).onEvent(event, context); + + handler.onScopeOpened(context); + verify(delegate).onScopeOpened(context); + + handler.onStop(context); + verify(delegate).onStop(context); + + handler.onScopeClosed(context); + verify(delegate).onScopeClosed(context); + + handler.onScopeReset(context); + verify(delegate).onScopeReset(context); + + handler.supportsContext(context); + verify(delegate).supportsContext(context); + } + + @Test + void spanShouldBeAvailableOnStop() { + Observation.Context observationContext = new Observation.Context(); + TracingObservationHandler.TracingContext tracingContext = new TracingObservationHandler.TracingContext(); + observationContext.put(TracingObservationHandler.TracingContext.class, tracingContext); + + Span span = mock(Span.class); + tracingContext.setSpan(span); + TraceContext traceContext = mock(TraceContext.class); + CurrentTraceContext currentTraceContext = mock(CurrentTraceContext.class); + CurrentTraceContext.Scope scope = mock(CurrentTraceContext.Scope.class); + + when(span.context()).thenReturn(traceContext); + when(tracer.currentTraceContext()).thenReturn(currentTraceContext); + when(currentTraceContext.maybeScope(traceContext)).thenReturn(scope); + + handler.onStop(observationContext); + + verify(scope).close(); + verify(currentTraceContext).maybeScope(traceContext); + verify(delegate).onStop(observationContext); + } + +}