Prompt content and completion as span events

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2024-08-17 16:14:02 +02:00
committed by Mark Pollack
parent 80fe5e4897
commit 3fa102e78f
12 changed files with 614 additions and 55 deletions

View File

@@ -84,6 +84,12 @@
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>

View File

@@ -17,10 +17,6 @@ package org.springframework.ai.chat.observation;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationFilter;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.util.StringJoiner;
/**
* An {@link ObservationFilter} to include the chat completion content in the observation.
@@ -36,25 +32,11 @@ public class ChatModelCompletionObservationFilter implements ObservationFilter {
return context;
}
if (chatModelObservationContext.getResponse() == null
|| chatModelObservationContext.getResponse().getResults() == null
|| CollectionUtils.isEmpty(chatModelObservationContext.getResponse().getResults())) {
return chatModelObservationContext;
}
var completions = ChatModelObservationContentProcessor.completion(chatModelObservationContext);
StringJoiner completionChoicesJoiner = new StringJoiner(", ", "[", "]");
chatModelObservationContext.getResponse()
.getResults()
.stream()
.filter(generation -> generation.getOutput() != null
&& StringUtils.hasText(generation.getOutput().getContent()))
.forEach(generation -> completionChoicesJoiner.add("\"" + generation.getOutput().getContent() + "\""));
if (StringUtils.hasText(chatModelObservationContext.getResponse().getResult().getOutput().getContent())) {
chatModelObservationContext
.addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.COMPLETION
.withValue(completionChoicesJoiner.toString()));
}
chatModelObservationContext
.addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.COMPLETION
.withValue(ChatModelObservationContentProcessor.concatenateStrings(completions)));
return chatModelObservationContext;
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 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.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.AiObservationAttributes;
import org.springframework.ai.observation.conventions.AiObservationEventNames;
/**
* Handler for including the chat completion content in the observation as a span event.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class ChatModelCompletionObservationHandler implements ObservationHandler<ChatModelObservationContext> {
@Override
public void onStop(ChatModelObservationContext context) {
TracingObservationHandler.TracingContext tracingContext = context
.get(TracingObservationHandler.TracingContext.class);
Span otelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
if (otelSpan != null) {
otelSpan.addEvent(AiObservationEventNames.CONTENT_COMPLETION.value(),
Attributes.of(AttributeKey.stringArrayKey(AiObservationAttributes.COMPLETION.value()),
ChatModelObservationContentProcessor.completion(context)));
}
}
@Override
public boolean supportsContext(Observation.Context context) {
return context instanceof ChatModelObservationContext;
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 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.tracing.handler.TracingObservationHandler;
import io.opentelemetry.api.trace.Span;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.model.Content;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.StringJoiner;
/**
* Utilities to process the prompt and completion content in observations for chat models.
*
* @author Thomas Vitale
*/
public final class ChatModelObservationContentProcessor {
private static final Logger logger = LoggerFactory.getLogger(ChatModelObservationContentProcessor.class);
public static List<String> prompt(ChatModelObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().getInstructions())) {
return List.of();
}
return context.getRequest().getInstructions().stream().map(Content::getContent).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().getContent())) {
return List.of();
}
return context.getResponse()
.getResults()
.stream()
.filter(generation -> generation.getOutput() != null
&& StringUtils.hasText(generation.getOutput().getContent()))
.map(generation -> generation.getOutput().getContent())
.toList();
}
public static String concatenateStrings(List<String> strings) {
var promptMessagesJoiner = new StringJoiner(", ", "[", "]");
strings.forEach(string -> promptMessagesJoiner.add("\"" + string + "\""));
return promptMessagesJoiner.toString();
}
@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;
}
}

View File

@@ -17,9 +17,6 @@ package org.springframework.ai.chat.observation;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationFilter;
import org.springframework.util.CollectionUtils;
import java.util.StringJoiner;
/**
* An {@link ObservationFilter} to include the chat prompt content in the observation.
@@ -35,18 +32,11 @@ public class ChatModelPromptContentObservationFilter implements ObservationFilte
return context;
}
if (CollectionUtils.isEmpty(chatModelObservationContext.getRequest().getInstructions())) {
return chatModelObservationContext;
}
StringJoiner promptMessagesJoiner = new StringJoiner(", ", "[", "]");
chatModelObservationContext.getRequest()
.getInstructions()
.forEach(message -> promptMessagesJoiner.add("\"" + message.getContent() + "\""));
var prompts = ChatModelObservationContentProcessor.prompt(chatModelObservationContext);
chatModelObservationContext
.addHighCardinalityKeyValue(ChatModelObservationDocumentation.HighCardinalityKeyNames.PROMPT
.withValue(promptMessagesJoiner.toString()));
.withValue(ChatModelObservationContentProcessor.concatenateStrings(prompts)));
return chatModelObservationContext;
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 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.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.AiObservationAttributes;
import org.springframework.ai.observation.conventions.AiObservationEventNames;
/**
* Handler for including the chat prompt content in the observation as a span event.
*
* @author Thomas Vitale
* @since 1.0.0
*/
public class ChatModelPromptContentObservationHandler implements ObservationHandler<ChatModelObservationContext> {
@Override
public void onStop(ChatModelObservationContext context) {
TracingObservationHandler.TracingContext tracingContext = context
.get(TracingObservationHandler.TracingContext.class);
Span otelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
if (otelSpan != null) {
otelSpan.addEvent(AiObservationEventNames.CONTENT_PROMPT.value(),
Attributes.of(AttributeKey.stringArrayKey(AiObservationAttributes.PROMPT.value()),
ChatModelObservationContentProcessor.prompt(context)));
}
}
@Override
public boolean supportsContext(Observation.Context context) {
return context instanceof ChatModelObservationContext;
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 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.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 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.ChatOptionsBuilder;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.observation.conventions.AiObservationAttributes;
import org.springframework.ai.observation.conventions.AiObservationEventNames;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ChatModelCompletionObservationHandler}.
*
* @author Thomas Vitale
*/
class ChatModelCompletionObservationHandlerTests {
@Test
void whenCompletionWithTextThenSpanEvent() {
var observationContext = ChatModelObservationContext.builder()
.prompt(new Prompt("supercalifragilisticexpialidocious"))
.provider("mary-poppins")
.requestOptions(ChatOptionsBuilder.builder().withModel("spoonful-of-sugar").build())
.build();
observationContext.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);
new ChatModelCompletionObservationHandler().onStop(observationContext);
var otelSpan = ChatModelObservationContentProcessor.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");
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright 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.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 java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ChatModelObservationContentProcessor}.
*
* @author Thomas Vitale
*/
class ChatModelObservationContentProcessorTests {
@Test
void extractOtelSpanWhenTracingContextIsNull() {
var actualOtelSpan = ChatModelObservationContentProcessor.extractOtelSpan(null);
assertThat(actualOtelSpan).isNull();
}
@Test
void extractOtelSpanWhenMethodDoesNotExist() {
var tracingContext = new TracingObservationHandler.TracingContext();
tracingContext.setSpan(Span.NOOP);
var actualOtelSpan = ChatModelObservationContentProcessor.extractOtelSpan(tracingContext);
assertThat(actualOtelSpan).isNull();
}
@Test
void extractOtelSpanWhenSpanIsNotOpenTelemetry() {
var tracingContext = new TracingObservationHandler.TracingContext();
tracingContext.setSpan(new DemoOtherSpan());
var actualOtelSpan = ChatModelObservationContentProcessor.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 = ChatModelObservationContentProcessor.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

@@ -0,0 +1,67 @@
/*
* Copyright 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.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 org.junit.jupiter.api.Test;
import org.springframework.ai.chat.prompt.ChatOptionsBuilder;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.observation.conventions.AiObservationAttributes;
import org.springframework.ai.observation.conventions.AiObservationEventNames;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link ChatModelPromptContentObservationHandler}.
*
* @author Thomas Vitale
*/
class ChatModelPromptContentObservationHandlerTests {
@Test
void whenPromptWithTextThenSpanEvent() {
var observationContext = ChatModelObservationContext.builder()
.prompt(new Prompt("supercalifragilisticexpialidocious"))
.provider("mary-poppins")
.requestOptions(ChatOptionsBuilder.builder().withModel("spoonful-of-sugar").build())
.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);
new ChatModelPromptContentObservationHandler().onStop(observationContext);
var otelSpan = ChatModelObservationContentProcessor.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");
}
}

View File

@@ -36,6 +36,12 @@
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>

View File

@@ -16,20 +16,25 @@
package org.springframework.ai.autoconfigure.chat.observation;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.tracing.otel.bridge.OtelTracer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.ChatModelPromptContentObservationFilter;
import org.springframework.ai.chat.observation.ChatModelPromptContentObservationHandler;
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.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Auto-configuration for Spring AI chat model observations.
@@ -52,24 +57,70 @@ public class ChatObservationAutoConfiguration {
return new ChatModelMeterObservationHandler(meterRegistry.getObject());
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-prompt",
havingValue = "true")
ChatModelPromptContentObservationFilter chatModelPromptObservationFilter() {
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!");
return new ChatModelPromptContentObservationFilter();
/**
* 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();
}
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-completion",
havingValue = "true")
ChatModelCompletionObservationFilter chatModelCompletionObservationFilter() {
@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();
}
}
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!");
}
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!");
return new ChatModelCompletionObservationFilter();
}
}

View File

@@ -16,10 +16,11 @@
package org.springframework.ai.autoconfigure.chat.observation;
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 org.junit.jupiter.api.Test;
import org.springframework.ai.chat.observation.ChatModelCompletionObservationFilter;
import org.springframework.ai.chat.observation.ChatModelMeterObservationHandler;
import org.springframework.ai.chat.observation.ChatModelPromptContentObservationFilter;
import org.springframework.ai.chat.observation.*;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -57,9 +58,26 @@ class ChatObservationAutoConfigurationTests {
}
@Test
void promptFilterEnabled() {
void promptHandlerDefault() {
contextRunner.run(context -> {
assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class);
});
}
@Test
void promptHandlerEnabled() {
contextRunner
.withBean(OtelTracer.class, OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null)
.withPropertyValues("spring.ai.chat.observations.include-prompt=true")
.run(context -> {
assertThat(context).hasSingleBean(ChatModelPromptContentObservationHandler.class);
});
}
@Test
void promptHandlerDisabled() {
contextRunner.withPropertyValues("spring.ai.chat.observations.include-prompt=true").run(context -> {
assertThat(context).hasSingleBean(ChatModelPromptContentObservationFilter.class);
assertThat(context).doesNotHaveBean(ChatModelPromptContentObservationHandler.class);
});
}
@@ -71,9 +89,26 @@ class ChatObservationAutoConfigurationTests {
}
@Test
void completionFilterEnabled() {
void completionHandlerDefault() {
contextRunner.run(context -> {
assertThat(context).doesNotHaveBean(ChatModelCompletionObservationHandler.class);
});
}
@Test
void completionHandlerEnabled() {
contextRunner
.withBean(OtelTracer.class, OpenTelemetry.noop().getTracer("test"), new OtelCurrentTraceContext(), null)
.withPropertyValues("spring.ai.chat.observations.include-completion=true")
.run(context -> {
assertThat(context).hasSingleBean(ChatModelCompletionObservationHandler.class);
});
}
@Test
void completionHandlerDisabled() {
contextRunner.withPropertyValues("spring.ai.chat.observations.include-completion=true").run(context -> {
assertThat(context).hasSingleBean(ChatModelCompletionObservationFilter.class);
assertThat(context).doesNotHaveBean(ChatModelCompletionObservationHandler.class);
});
}