From 3fa102e78ff7cc1a19d8d10da61cf4936c871ee1 Mon Sep 17 00:00:00 2001 From: Thomas Vitale Date: Sat, 17 Aug 2024 16:14:02 +0200 Subject: [PATCH] Prompt content and completion as span events Signed-off-by: Thomas Vitale --- spring-ai-core/pom.xml | 6 + .../ChatModelCompletionObservationFilter.java | 26 +--- ...ChatModelCompletionObservationHandler.java | 53 +++++++ .../ChatModelObservationContentProcessor.java | 99 ++++++++++++ ...atModelPromptContentObservationFilter.java | 14 +- ...tModelPromptContentObservationHandler.java | 54 +++++++ ...odelCompletionObservationHandlerTests.java | 74 +++++++++ ...ModelObservationContentProcessorTests.java | 142 ++++++++++++++++++ ...lPromptContentObservationHandlerTests.java | 67 +++++++++ spring-ai-spring-boot-autoconfigure/pom.xml | 6 + .../ChatObservationAutoConfiguration.java | 75 +++++++-- ...ChatObservationAutoConfigurationTests.java | 49 +++++- 12 files changed, 612 insertions(+), 53 deletions(-) create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java create mode 100644 spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessorTests.java create mode 100644 spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java diff --git a/spring-ai-core/pom.xml b/spring-ai-core/pom.xml index 9b600c4b46..10bfcbb4c4 100644 --- a/spring-ai-core/pom.xml +++ b/spring-ai-core/pom.xml @@ -84,6 +84,12 @@ micrometer-core + + io.micrometer + micrometer-tracing-bridge-otel + true + + com.knuddels jtokkit diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java index 6d70a80bb6..aadc292de0 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationFilter.java @@ -17,10 +17,6 @@ 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 Observation.Context map(Observation.Context context) { return context; } - if (chatModelObservationContext.getResponse() == null - || chatModelObservationContext.getResponse().getResults() == null - || CollectionUtils.isEmpty(chatModelObservationContext.getResponse().getResults())) { - return 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() + "\"")); + var completions = ChatModelObservationContentProcessor.completion(chatModelObservationContext); - 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; } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java new file mode 100644 index 0000000000..f6c2efe80e --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandler.java @@ -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 { + + @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; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java new file mode 100644 index 0000000000..e299c67411 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessor.java @@ -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 prompt(ChatModelObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().getInstructions())) { + return List.of(); + } + + return context.getRequest().getInstructions().stream().map(Content::getContent).toList(); + } + + public static List completion(ChatModelObservationContext context) { + if (context == null || context.getResponse() == null || context.getResponse().getResults() == null + || CollectionUtils.isEmpty(context.getResponse().getResults())) { + return List.of(); + } + + if (!StringUtils.hasText(context.getResponse().getResult().getOutput().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 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; + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java index 758ff0c7dc..785fddf92c 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationFilter.java @@ -17,9 +17,6 @@ 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 Observation.Context map(Observation.Context context) { 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; } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java new file mode 100644 index 0000000000..61866a4730 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandler.java @@ -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 { + + @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; + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java new file mode 100644 index 0000000000..261493c134 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelCompletionObservationHandlerTests.java @@ -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"); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessorTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessorTests.java new file mode 100644 index 0000000000..229a4ae3a1 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelObservationContentProcessorTests.java @@ -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; + } + + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java new file mode 100644 index 0000000000..681327e8e0 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/observation/ChatModelPromptContentObservationHandlerTests.java @@ -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"); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index 7a21efbfd7..dface18993 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -36,6 +36,12 @@ spring-boot-starter + + io.micrometer + micrometer-tracing-bridge-otel + true + + org.springframework.ai spring-ai-openai diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java index aabc1b6b0b..8dfa9323a1 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfiguration.java @@ -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 @@ ChatModelMeterObservationHandler chatModelMeterObservationHandler(ObjectProvider return new ChatModelMeterObservationHandler(meterRegistry.getObject()); } - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-prompt", - havingValue = "true") - ChatModelPromptContentObservationFilter chatModelPromptObservationFilter() { + /** + * 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(); + } + + } + + 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!"); - return new ChatModelPromptContentObservationFilter(); } - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(prefix = ChatObservationProperties.CONFIG_PREFIX, name = "include-completion", - havingValue = "true") - ChatModelCompletionObservationFilter chatModelCompletionObservationFilter() { + 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(); } } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java index 98dcb33ee0..7e4f01a694 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/observation/ChatObservationAutoConfigurationTests.java @@ -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 @@ void promptFilterDefault() { } @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 @@ void completionFilterDefault() { } @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); }); }