feat(observability): refactor content observation to use logging instead of tracing

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 <christian.tzolov@broadcom.com>
This commit is contained in:
Jonatan Ivanov
2025-05-05 18:18:57 -07:00
committed by Christian Tzolov
parent 72a2862bce
commit ca843e8588
55 changed files with 1032 additions and 1619 deletions

View File

@@ -30,6 +30,12 @@
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing</artifactId>
<optional>true</optional>
</dependency>
<!-- Boot dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -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<ChatClientCustomizer> 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<ChatClientObservationContext> 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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -32,7 +32,7 @@
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
<artifactId>micrometer-tracing</artifactId>
<optional>true</optional>
</dependency>

View File

@@ -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<ChatModelObservationContext> 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<ChatModelObservationContext> 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();
}
}
}

View File

@@ -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() {

View File

@@ -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));
}

View File

@@ -30,6 +30,12 @@
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing</artifactId>
<optional>true</optional>
</dependency>
<!-- Boot dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -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<ImageModelObservationContext> 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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -45,7 +45,7 @@
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
<artifactId>micrometer-tracing</artifactId>
<optional>true</optional>
</dependency>
<dependency>

View File

@@ -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<VectorStoreObservationContext> 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();
}
}
}

View File

@@ -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));
}
}

View File

@@ -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();
}
},
}
}

View File

@@ -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<ChatClientObservationContext> {
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<String, Object> 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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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"]"""));
}
}

View File

@@ -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"]
""");
}
}

View File

@@ -53,9 +53,15 @@
<artifactId>context-propagation</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
<artifactId>micrometer-tracing</artifactId>
<optional>true</optional>
</dependency>

View File

@@ -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<String> documents(VectorStoreObservationContext context) {
if (CollectionUtils.isEmpty(context.getQueryResponse())) {
return List.of();
}
public static String concatenateEntries(Map<String, Object> 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<String> strings) {
var stringsJoiner = new StringJoiner(", ", "[", "]");
strings.forEach(string -> stringsJoiner.add("\"" + string + "\""));
return stringsJoiner.toString();
}
}

View File

@@ -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 <T> type of handler context
* @author Jonatan Ivanov
* @since 1.0.0
*/
public class TracingAwareLoggingObservationHandler<T extends Observation.Context> implements ObservationHandler<T> {
private final ObservationHandler<T> 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<T> 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);
}
}

View File

@@ -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;

View File

@@ -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 <a href=
* "https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai">OTel
* Semantic Conventions</a>.
*/
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
}

View File

@@ -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
}

View File

@@ -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<String, Object> keyValues) {
var keyValuesJoiner = new StringJoiner(", ", "[", "]");
keyValues.forEach((key, value) -> keyValuesJoiner.add("\"" + key + "\":\"" + value + "\""));
return keyValuesJoiner.toString();
}
public static String concatenateStrings(List<String> strings) {
var stringsJoiner = new StringJoiner(", ", "[", "]");
strings.forEach(string -> stringsJoiner.add("\"" + string + "\""));
return stringsJoiner.toString();
}
}

View File

@@ -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<String, Object> 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\"]");
}
}

View File

@@ -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;
}
}
}

View File

@@ -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!

View File

@@ -60,11 +60,10 @@
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
<artifactId>micrometer-tracing</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>

View File

@@ -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;
}
}

View File

@@ -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<ChatModelObservationContext> {
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<String> 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

View File

@@ -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<String> prompt(ChatModelObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().getInstructions())) {
return List.of();
}
return context.getRequest().getInstructions().stream().map(Content::getText).toList();
}
public static List<String> 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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<ChatModelObservationContext> {
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<String> prompt(ChatModelObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().getInstructions())) {
return List.of();
}
return context.getRequest().getInstructions().stream().map(Content::getText).toList();
}
@Override

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<ImageModelObservationContext> {
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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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\"]"));
}
}

View File

@@ -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"]
""");
}
}

View File

@@ -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\"]"));
}
}

View File

@@ -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"]
""");
}
}

View File

@@ -0,0 +1,29 @@
<!--
~ 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.
-->
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n</pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@@ -47,12 +47,6 @@
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
<optional>true</optional>
</dependency>
<!-- test dependencies -->
<dependency>

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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<VectorStoreObservationContext> {
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<String> documents(VectorStoreObservationContext context) {
if (CollectionUtils.isEmpty(context.getQueryResponse())) {
return List.of();
}
return context.getQueryResponse().stream().map(Document::getText).toList();
}
@Override

View File

@@ -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"),

View File

@@ -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<Document> 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\"]"));
}
}

View File

@@ -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"]
""");
}
}

View File

@@ -0,0 +1,29 @@
<!--
~ 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.
-->
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n</pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="STDOUT"/>
</root>
</configuration>