Prompt content and completion as span events
Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
committed by
Mark Pollack
parent
80fe5e4897
commit
3fa102e78f
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user