From 8874fded971c6979bf0ecb52794b60b34aedaf92 Mon Sep 17 00:00:00 2001 From: Minghui Zhang <84360903+Cirilla-zmh@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:25:51 +0800 Subject: [PATCH] Support observability of saa studio (#2358) --- .../pom.xml | 6 + .../arms/ArmsAutoConfiguration.java | 41 ++++++- .../arms/ArmsCommonProperties.java | 78 ++++++++++++ pom.xml | 3 + .../ArmsToolCallingObservationConvention.java | 3 +- .../pom.xml | 95 +++++++++++++++ ...aAwareChatClientObservationConvention.java | 114 ++++++++++++++++++ .../constants/MetadataAttributes.java | 19 +++ .../ChatModelInputObservationHandler.java | 80 ++++++++++++ .../ChatModelOutputObservationHandler.java | 87 +++++++++++++ .../model/OpenTelemetrySpanBridge.java | 46 +++++++ ...taAwareChatModelObservationConvention.java | 114 ++++++++++++++++++ .../model/semconv/InputOutputModel.java | 71 +++++++++++ .../model/semconv/InputOutputUtils.java | 83 +++++++++++++ .../model/semconv/MessageMode.java | 7 ++ .../src/main/resources/application.properties | 1 + 16 files changed, 846 insertions(+), 2 deletions(-) create mode 100644 spring-ai-alibaba-observation-extension/pom.xml create mode 100644 spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/client/prompt/PromptMetadataAwareChatClientObservationConvention.java create mode 100644 spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/constants/MetadataAttributes.java create mode 100644 spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/ChatModelInputObservationHandler.java create mode 100644 spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/ChatModelOutputObservationHandler.java create mode 100644 spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/OpenTelemetrySpanBridge.java create mode 100644 spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/PromptMetadataAwareChatModelObservationConvention.java create mode 100644 spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/InputOutputModel.java create mode 100644 spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/InputOutputUtils.java create mode 100644 spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/MessageMode.java create mode 100644 spring-ai-alibaba-observation-extension/src/main/resources/application.properties diff --git a/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/pom.xml b/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/pom.xml index 7b8a34cf71..8c79e04a7f 100644 --- a/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/pom.xml +++ b/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/pom.xml @@ -26,6 +26,12 @@ ${revision} + + com.alibaba.cloud.ai + spring-ai-alibaba-observation-extension + ${revision} + + org.springframework.boot spring-boot-starter diff --git a/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/src/main/java/com/alibaba/cloud/ai/autoconfigure/arms/ArmsAutoConfiguration.java b/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/src/main/java/com/alibaba/cloud/ai/autoconfigure/arms/ArmsAutoConfiguration.java index 49812f1fad..90c2f3bd84 100644 --- a/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/src/main/java/com/alibaba/cloud/ai/autoconfigure/arms/ArmsAutoConfiguration.java +++ b/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/src/main/java/com/alibaba/cloud/ai/autoconfigure/arms/ArmsAutoConfiguration.java @@ -15,26 +15,37 @@ */ package com.alibaba.cloud.ai.autoconfigure.arms; +import com.alibaba.cloud.ai.observation.client.prompt.PromptMetadataAwareChatClientObservationConvention; +import com.alibaba.cloud.ai.observation.model.ChatModelInputObservationHandler; +import com.alibaba.cloud.ai.observation.model.ChatModelOutputObservationHandler; +import com.alibaba.cloud.ai.observation.model.PromptMetadataAwareChatModelObservationConvention; import com.alibaba.cloud.ai.tool.ObservableToolCallingManager; import io.micrometer.observation.ObservationRegistry; +import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor; import org.springframework.ai.tool.resolution.ToolCallbackResolver; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +/** + * @author Lumian + */ @AutoConfiguration @ConditionalOnClass(ChatModel.class) +@ConditionalOnProperty(prefix = ArmsCommonProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true") @EnableConfigurationProperties(ArmsCommonProperties.class) public class ArmsAutoConfiguration { @Bean - @ConditionalOnProperty(prefix = ArmsCommonProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true") + @ConditionalOnProperty(prefix = ArmsCommonProperties.CONFIG_PREFIX, name = "tool.enabled", havingValue = "true") ToolCallingManager toolCallingManager(ToolCallbackResolver toolCallbackResolver, ToolExecutionExceptionProcessor toolExecutionExceptionProcessor, ObjectProvider observationRegistry) { @@ -45,4 +56,32 @@ ToolCallingManager toolCallingManager(ToolCallbackResolver toolCallbackResolver, .build(); } + @Bean + ChatClientObservationConvention chatClientObservationConvention() { + return new PromptMetadataAwareChatClientObservationConvention(); + } + + @Bean + ChatModelObservationConvention chatModelObservationConvention() { + return new PromptMetadataAwareChatModelObservationConvention(); + } + + @Bean + @ConditionalOnMissingBean(value = { ChatModelInputObservationHandler.class }, + name = { "chatModelInputObservationHandler" }) + @ConditionalOnProperty(prefix = ArmsCommonProperties.CONFIG_PREFIX, name = "model.capture-input", + havingValue = "true") + ChatModelInputObservationHandler armsChatModelInputObservationHandler(ArmsCommonProperties properties) { + return new ChatModelInputObservationHandler(properties.getModel().getMessageMode()); + } + + @Bean + @ConditionalOnMissingBean(value = { ChatModelOutputObservationHandler.class }, + name = { "chatModelOutputObservationHandler" }) + @ConditionalOnProperty(prefix = ArmsCommonProperties.CONFIG_PREFIX, name = "model.capture-output", + havingValue = "true") + ChatModelOutputObservationHandler armsChatModelOutputObservationHandler(ArmsCommonProperties properties) { + return new ChatModelOutputObservationHandler(properties.getModel().getMessageMode()); + } + } diff --git a/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/src/main/java/com/alibaba/cloud/ai/autoconfigure/arms/ArmsCommonProperties.java b/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/src/main/java/com/alibaba/cloud/ai/autoconfigure/arms/ArmsCommonProperties.java index 0518f8fdb7..389537417f 100644 --- a/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/src/main/java/com/alibaba/cloud/ai/autoconfigure/arms/ArmsCommonProperties.java +++ b/auto-configurations/spring-ai-alibaba-autoconfigure-arms-observation/src/main/java/com/alibaba/cloud/ai/autoconfigure/arms/ArmsCommonProperties.java @@ -15,6 +15,7 @@ */ package com.alibaba.cloud.ai.autoconfigure.arms; +import com.alibaba.cloud.ai.observation.model.semconv.MessageMode; import org.springframework.boot.context.properties.ConfigurationProperties; /** @@ -33,6 +34,10 @@ public class ArmsCommonProperties { */ private boolean enabled = false; + private ModelProperties model = new ModelProperties(); + + private ToolProperties tool = new ToolProperties(); + public boolean isEnabled() { return enabled; } @@ -41,4 +46,77 @@ public void setEnabled(boolean enabled) { this.enabled = enabled; } + public ModelProperties getModel() { + return model; + } + + public void setModel(ModelProperties model) { + this.model = model; + } + + public ToolProperties getTool() { + return tool; + } + + public void setTool(ToolProperties tool) { + this.tool = tool; + } + + public static class ModelProperties { + + /** + * Enable Arms instrumentations and conventions. + */ + private boolean captureInput = false; + + /** + * Enable Arms instrumentations and conventions. + */ + private boolean captureOutput = false; + + /** + * Arms export type enumeration. + */ + private MessageMode messageMode = MessageMode.OPEN_TELEMETRY; + + public boolean isCaptureInput() { + return captureInput; + } + + public void setCaptureInput(boolean captureInput) { + this.captureInput = captureInput; + } + + public boolean isCaptureOutput() { + return captureOutput; + } + + public void setCaptureOutput(boolean captureOutput) { + this.captureOutput = captureOutput; + } + + public MessageMode getMessageMode() { + return messageMode; + } + + public void setMessageMode(MessageMode messageMode) { + this.messageMode = messageMode; + } + + } + + public static class ToolProperties { + + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + } + } diff --git a/pom.xml b/pom.xml index fec9bb05af..3328a9235f 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,9 @@ spring-ai-alibaba-jmanus spring-ai-alibaba-deepresearch + + spring-ai-alibaba-observation-extension + community/tool-calls/spring-ai-alibaba-starter-tool-calling-common community/tool-calls/spring-ai-alibaba-starter-tool-calling-time diff --git a/spring-ai-alibaba-core/src/main/java/com/alibaba/cloud/ai/tool/observation/ArmsToolCallingObservationConvention.java b/spring-ai-alibaba-core/src/main/java/com/alibaba/cloud/ai/tool/observation/ArmsToolCallingObservationConvention.java index c07f142781..aeb3d84b25 100644 --- a/spring-ai-alibaba-core/src/main/java/com/alibaba/cloud/ai/tool/observation/ArmsToolCallingObservationConvention.java +++ b/spring-ai-alibaba-core/src/main/java/com/alibaba/cloud/ai/tool/observation/ArmsToolCallingObservationConvention.java @@ -105,7 +105,8 @@ protected KeyValues toolDescription(KeyValues keyValues, ArmsToolCallingObservat protected KeyValues toolParameters(KeyValues keyValues, ArmsToolCallingObservationContext context) { if (context.getToolCall().arguments() != null) { - return keyValues.and(HighCardinalityKeyNames.TOOL_PARAMETERS.asString(), context.getToolCall().arguments()); + return keyValues.and(HighCardinalityKeyNames.TOOL_PARAMETERS.asString(), context.getToolCall().arguments()) + .and(HighCardinalityKeyNames.INPUT_VALUE.asString(), context.getToolCall().arguments()); } return keyValues; } diff --git a/spring-ai-alibaba-observation-extension/pom.xml b/spring-ai-alibaba-observation-extension/pom.xml new file mode 100644 index 0000000000..fcf5d49400 --- /dev/null +++ b/spring-ai-alibaba-observation-extension/pom.xml @@ -0,0 +1,95 @@ + + + + 4.0.0 + + com.alibaba.cloud.ai + spring-ai-alibaba + ${revision} + ../pom.xml + + spring-ai-alibaba-observation-extension + ${revision} + jar + Spring AI Alibaba Observation Extension + Spring AI Alibaba Observation Extension Module, ARMS Implementation + + https://github.com/alibaba/spring-ai-alibaba + + + git://github.com/alibaba/spring-ai-alibaba.git + git@github.com:alibaba/spring-ai-alibaba.git + https://github.com/alibaba/spring-ai-alibaba + + + + + org.springframework.ai + spring-ai-model + + + + + org.springframework.ai + spring-ai-openai + + + + org.springframework.ai + spring-ai-client-chat + + + + org.springframework.ai + spring-ai-commons + + + + io.micrometer + micrometer-tracing + true + + + + io.micrometer + micrometer-tracing-bridge-otel + true + + + + io.micrometer + micrometer-observation-test + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/client/prompt/PromptMetadataAwareChatClientObservationConvention.java b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/client/prompt/PromptMetadataAwareChatClientObservationConvention.java new file mode 100644 index 0000000000..7b8812ded8 --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/client/prompt/PromptMetadataAwareChatClientObservationConvention.java @@ -0,0 +1,114 @@ +package com.alibaba.cloud.ai.observation.client.prompt; + +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.AGENT_IP; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.AGENT_NAME; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.PROMPT_KEY; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.PROMPT_TEMPLATE; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.PROMPT_VARIABLE; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.PROMPT_VERSION; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.STUDIO_SOURCE; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import java.util.Collections; +import java.util.Map; +import org.springframework.ai.chat.client.observation.ChatClientObservationContext; +import org.springframework.ai.chat.client.observation.DefaultChatClientObservationConvention; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.lang.NonNull; + +public class PromptMetadataAwareChatClientObservationConvention extends DefaultChatClientObservationConvention { + + @Override + @NonNull + public KeyValues getLowCardinalityKeyValues(@NonNull ChatClientObservationContext context) { + return super.getLowCardinalityKeyValues(context); + } + + @Override + @NonNull + public KeyValues getHighCardinalityKeyValues(@NonNull ChatClientObservationContext context) { + KeyValues keyValues = super.getHighCardinalityKeyValues(context); + keyValues = promptKey(keyValues, context); + keyValues = promptVersion(keyValues, context); + keyValues = promptTemplate(keyValues, context); + keyValues = promptVariables(keyValues, context); + keyValues = agentName(keyValues, context); + keyValues = agentIp(keyValues, context); + keyValues = studioSource(keyValues, context); + + return keyValues; + } + + // request + + protected KeyValues promptKey(KeyValues keyValues, ChatClientObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("promptKey")) { + return keyValues.and(KeyValue.of(PROMPT_KEY, metadata.get("promptKey"))); + } + return keyValues; + } + + protected KeyValues promptVersion(KeyValues keyValues, ChatClientObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("promptVersion")) { + return keyValues.and(KeyValue.of(PROMPT_VERSION, metadata.get("promptVersion"))); + } + return keyValues; + } + + protected KeyValues promptTemplate(KeyValues keyValues, ChatClientObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("promptTemplate")) { + return keyValues.and(KeyValue.of(PROMPT_TEMPLATE, metadata.get("promptTemplate"))); + } + return keyValues; + } + + protected KeyValues promptVariables(KeyValues keyValues, ChatClientObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("promptVariables")) { + return keyValues.and(KeyValue.of(PROMPT_VARIABLE, metadata.get("promptVariables"))); + } + return keyValues; + } + + protected KeyValues agentName(KeyValues keyValues, ChatClientObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("agentName")) { + return keyValues.and(KeyValue.of(AGENT_NAME, metadata.get("agentName"))); + } + return keyValues; + } + + protected KeyValues agentIp(KeyValues keyValues, ChatClientObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("agentIp")) { + return keyValues.and(KeyValue.of(AGENT_IP, metadata.get("agentIp"))); + } + return keyValues; + } + + protected KeyValues studioSource(KeyValues keyValues, ChatClientObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("studioSource")) { + return keyValues.and(KeyValue.of(STUDIO_SOURCE, metadata.get("studioSource"))); + } + return keyValues; + } + + // FIXME remove openai assert + private Map getMetadata(ChatClientObservationContext context) { + ChatOptions options = context.getRequest().prompt().getOptions(); + if (options instanceof OpenAiChatOptions) { + Map metadata = ((OpenAiChatOptions) options).getMetadata(); + if (metadata != null) { + return metadata; + } + } + return Collections.emptyMap(); + } + +} diff --git a/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/constants/MetadataAttributes.java b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/constants/MetadataAttributes.java new file mode 100644 index 0000000000..d5ae2a83ac --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/constants/MetadataAttributes.java @@ -0,0 +1,19 @@ +package com.alibaba.cloud.ai.observation.constants; + +public final class MetadataAttributes { + + public static final String PROMPT_KEY = "spring.ai.alibaba.prompt.key"; + + public static final String PROMPT_VERSION = "spring.ai.alibaba.prompt.version"; + + public static final String PROMPT_TEMPLATE = "spring.ai.alibaba.prompt.template"; + + public static final String PROMPT_VARIABLE = "spring.ai.alibaba.prompt.variable"; + + public static final String AGENT_NAME = "spring.ai.alibaba.agent.name"; + + public static final String AGENT_IP = "spring.ai.alibaba.agent.ip"; + + public static final String STUDIO_SOURCE = "spring.ai.alibaba.studio.source"; + +} diff --git a/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/ChatModelInputObservationHandler.java b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/ChatModelInputObservationHandler.java new file mode 100644 index 0000000000..4eb19f9a81 --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/ChatModelInputObservationHandler.java @@ -0,0 +1,80 @@ +package com.alibaba.cloud.ai.observation.model; + +import static com.alibaba.cloud.ai.observation.model.semconv.MessageMode.LANGFUSE; +import static io.opentelemetry.api.common.AttributeKey.stringKey; + +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputModel.ChatMessage; +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputUtils; +import com.alibaba.cloud.ai.observation.model.semconv.MessageMode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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.trace.Span; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.observation.ChatModelObservationContext; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; + +public class ChatModelInputObservationHandler implements ObservationHandler { + + private static final Logger logger = LoggerFactory.getLogger(ChatModelInputObservationHandler.class); + + private final AttributeKey inputMessagesKey; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ChatModelInputObservationHandler(MessageMode mode) { + if (mode == LANGFUSE) { + inputMessagesKey = stringKey("input.values"); + } + else { + inputMessagesKey = stringKey("gen_ai.input.messages"); + } + } + + @Override + public void onStop(ChatModelObservationContext context) { + TracingObservationHandler.TracingContext tracingContext = context + .get(TracingObservationHandler.TracingContext.class); + Span otelSpan = OpenTelemetrySpanBridge.retrieveOtelSpan(tracingContext); + + if (otelSpan != null) { + String outputMessages = getOutputMessages(context); + if (outputMessages != null) { + otelSpan.setAttribute(inputMessagesKey, outputMessages); + } + } + } + + @Nullable + private String getOutputMessages(ChatModelObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().getInstructions())) { + return null; + } + + List messages = context.getRequest() + .getInstructions() + .stream() + .map(InputOutputUtils::convertFromMessage) + .toList(); + + try { + return objectMapper.writeValueAsString(messages); + } + catch (JsonProcessingException e) { + logger.warn("Failed to convert output message to JSON string", e); + return null; + } + } + + @Override + public boolean supportsContext(Observation.Context context) { + return context instanceof ChatModelObservationContext; + } + +} diff --git a/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/ChatModelOutputObservationHandler.java b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/ChatModelOutputObservationHandler.java new file mode 100644 index 0000000000..febdaf8676 --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/ChatModelOutputObservationHandler.java @@ -0,0 +1,87 @@ +package com.alibaba.cloud.ai.observation.model; + +import static com.alibaba.cloud.ai.observation.model.semconv.MessageMode.LANGFUSE; + +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputModel.OutputMessage; +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputUtils; +import com.alibaba.cloud.ai.observation.model.semconv.MessageMode; +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.trace.Span; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.observation.ChatModelObservationContext; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class ChatModelOutputObservationHandler implements ObservationHandler { + + private static final Logger logger = LoggerFactory.getLogger(ChatModelOutputObservationHandler.class); + + private final AttributeKey outputMessagesKey; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ChatModelOutputObservationHandler(MessageMode mode) { + if (mode == LANGFUSE) { + outputMessagesKey = AttributeKey.stringKey("output.values"); + } + else { + outputMessagesKey = AttributeKey.stringKey("gen_ai.output.messages"); + } + } + + @Override + public void onStop(ChatModelObservationContext context) { + TracingObservationHandler.TracingContext tracingContext = context + .get(TracingObservationHandler.TracingContext.class); + Span otelSpan = OpenTelemetrySpanBridge.retrieveOtelSpan(tracingContext); + + if (otelSpan != null) { + String outputMessages = getOutputMessages(context); + if (outputMessages != null) { + otelSpan.setAttribute(outputMessagesKey, outputMessages); + } + } + } + + @Nullable + private String getOutputMessages(ChatModelObservationContext context) { + if (context.getResponse() == null || context.getResponse().getResults() == null + || CollectionUtils.isEmpty(context.getResponse().getResults())) { + return null; + } + + if (!StringUtils.hasText(context.getResponse().getResult().getOutput().getText())) { + return ""; + } + + List messages = context.getResponse() + .getResults() + .stream() + .filter(generation -> generation.getOutput() != null && generation.getMetadata() != null) + .map(InputOutputUtils::convertFromGeneration) + .toList(); + + try { + return objectMapper.writeValueAsString(messages); + } + catch (JsonProcessingException e) { + logger.warn("Failed to convert output message to JSON string", e); + return null; + } + } + + @Override + public boolean supportsContext(Observation.Context context) { + return context instanceof ChatModelObservationContext; + } + +} diff --git a/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/OpenTelemetrySpanBridge.java b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/OpenTelemetrySpanBridge.java new file mode 100644 index 0000000000..0cce58abe3 --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/OpenTelemetrySpanBridge.java @@ -0,0 +1,46 @@ +package com.alibaba.cloud.ai.observation.model; + +import io.micrometer.tracing.handler.TracingObservationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import io.opentelemetry.api.trace.Span; + +public final class OpenTelemetrySpanBridge { + + private static final Logger logger = LoggerFactory.getLogger(OpenTelemetrySpanBridge.class); + + private static Method toOtelMethod; + + @Nullable + public static Span retrieveOtelSpan(@Nullable TracingObservationHandler.TracingContext tracingContext) { + if (tracingContext == null) { + return null; + } + + io.micrometer.tracing.Span micrometerSpan = tracingContext.getSpan(); + try { + if (toOtelMethod == null) { + Method method = tracingContext.getSpan() + .getClass() + .getDeclaredMethod("toOtel", io.micrometer.tracing.Span.class); + method.setAccessible(true); + toOtelMethod = method; + } + + Object otelSpanObject = toOtelMethod.invoke(null, micrometerSpan); + if (otelSpanObject instanceof Span otelSpan) { + return otelSpan; + } + } + catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException ex) { + logger.warn("Failed to retrieve the OpenTelemetry Span from Micrometer context", ex); + return null; + } + + return null; + } + +} diff --git a/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/PromptMetadataAwareChatModelObservationConvention.java b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/PromptMetadataAwareChatModelObservationConvention.java new file mode 100644 index 0000000000..f88a0302bf --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/PromptMetadataAwareChatModelObservationConvention.java @@ -0,0 +1,114 @@ +package com.alibaba.cloud.ai.observation.model; + +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.AGENT_IP; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.AGENT_NAME; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.PROMPT_KEY; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.PROMPT_TEMPLATE; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.PROMPT_VARIABLE; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.PROMPT_VERSION; +import static com.alibaba.cloud.ai.observation.constants.MetadataAttributes.STUDIO_SOURCE; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import java.util.Collections; +import java.util.Map; +import org.springframework.ai.chat.observation.ChatModelObservationContext; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.lang.NonNull; + +public class PromptMetadataAwareChatModelObservationConvention extends DefaultChatModelObservationConvention { + + @Override + @NonNull + public KeyValues getLowCardinalityKeyValues(@NonNull ChatModelObservationContext context) { + return super.getLowCardinalityKeyValues(context); + } + + @Override + @NonNull + public KeyValues getHighCardinalityKeyValues(@NonNull ChatModelObservationContext context) { + KeyValues keyValues = super.getHighCardinalityKeyValues(context); + keyValues = promptKey(keyValues, context); + keyValues = promptVersion(keyValues, context); + keyValues = promptTemplate(keyValues, context); + keyValues = promptVariables(keyValues, context); + keyValues = agentName(keyValues, context); + keyValues = agentIp(keyValues, context); + keyValues = studioSource(keyValues, context); + + return keyValues; + } + + // request + + protected KeyValues promptKey(KeyValues keyValues, ChatModelObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("promptKey")) { + return keyValues.and(KeyValue.of(PROMPT_KEY, metadata.get("promptKey"))); + } + return keyValues; + } + + protected KeyValues promptVersion(KeyValues keyValues, ChatModelObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("promptVersion")) { + return keyValues.and(KeyValue.of(PROMPT_VERSION, metadata.get("promptVersion"))); + } + return keyValues; + } + + protected KeyValues promptTemplate(KeyValues keyValues, ChatModelObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("promptTemplate")) { + return keyValues.and(KeyValue.of(PROMPT_TEMPLATE, metadata.get("promptTemplate"))); + } + return keyValues; + } + + protected KeyValues promptVariables(KeyValues keyValues, ChatModelObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("promptVariables")) { + return keyValues.and(KeyValue.of(PROMPT_VARIABLE, metadata.get("promptVariables"))); + } + return keyValues; + } + + protected KeyValues agentName(KeyValues keyValues, ChatModelObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("agentName")) { + return keyValues.and(KeyValue.of(AGENT_NAME, metadata.get("agentName"))); + } + return keyValues; + } + + protected KeyValues agentIp(KeyValues keyValues, ChatModelObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("agentIp")) { + return keyValues.and(KeyValue.of(AGENT_IP, metadata.get("agentIp"))); + } + return keyValues; + } + + protected KeyValues studioSource(KeyValues keyValues, ChatModelObservationContext context) { + Map metadata = this.getMetadata(context); + if (metadata.containsKey("studioSource")) { + return keyValues.and(KeyValue.of(STUDIO_SOURCE, metadata.get("studioSource"))); + } + return keyValues; + } + + // FIXME remove openai assert + private Map getMetadata(ChatModelObservationContext context) { + ChatOptions options = context.getRequest().getOptions(); + if (options instanceof OpenAiChatOptions) { + Map metadata = ((OpenAiChatOptions) options).getMetadata(); + if (metadata != null) { + return metadata; + } + } + return Collections.emptyMap(); + } + +} diff --git a/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/InputOutputModel.java b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/InputOutputModel.java new file mode 100644 index 0000000000..3cb0f0bc31 --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/InputOutputModel.java @@ -0,0 +1,71 @@ +package com.alibaba.cloud.ai.observation.model.semconv; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import java.util.List; + +public final class InputOutputModel { + + public interface MessagePart { + + } + + @JsonClassDescription("Chat message") + public record ChatMessage( + @JsonProperty(required = true, value = "role") @JsonPropertyDescription("Role of response") String role, + @JsonProperty(required = true, + value = "parts") @JsonPropertyDescription("List of message parts that make up the message content") List parts) { + } + + @JsonClassDescription("Output message") + public record OutputMessage( + @JsonProperty(required = true, value = "role") @JsonPropertyDescription("Role of response") String role, + @JsonProperty(required = true, + value = "parts") @JsonPropertyDescription("List of message parts that make up the message content") List parts, + @JsonProperty(required = true, + value = "finish_reason") @JsonPropertyDescription("Reason for finishing the generation") String finishReason) { + } + + public enum RoleEnum { + + UNKNOWN("unknown"), USER("user"), ASSISTANT("assistant"), SYSTEM("system"), TOOL("tool"); + + public final String value; + + RoleEnum(String value) { + this.value = value; + } + + } + + @JsonClassDescription("Text content sent to or received from the model") + public record TextPart(@JsonProperty(required = true, + value = "type") @JsonPropertyDescription("The type of the content captured in this part") String type, + @JsonProperty(required = true, + value = "content") @JsonPropertyDescription("Text content sent to or received from the model") String content) + implements + MessagePart { + } + + @JsonClassDescription("A tool call requested by the model") + public record ToolCallRequestPart(@JsonProperty(required = true, + value = "type") @JsonPropertyDescription("The type of the content captured in this part") String type, + @JsonProperty(required = true, value = "name") @JsonPropertyDescription("Name of the tool") String name, + @JsonProperty(value = "id") @JsonPropertyDescription("Unique identifier for the tool call") String id, + @JsonProperty(value = "arguments") @JsonPropertyDescription("Arguments for the tool call") String arguments) + implements + MessagePart { + } + + @JsonClassDescription("A tool call result sent to the model or a built-in tool call outcome and details") + public record ToolCallResponsePart(@JsonProperty(required = true, + value = "type") @JsonPropertyDescription("The type of the content captured in this part") String type, + @JsonProperty(required = true, + value = "response") @JsonPropertyDescription("Tool call response") String response, + @JsonProperty(value = "id") @JsonPropertyDescription("Unique tool call identifier") String id) + implements + MessagePart { + } + +} diff --git a/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/InputOutputUtils.java b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/InputOutputUtils.java new file mode 100644 index 0000000000..bf80e87507 --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/InputOutputUtils.java @@ -0,0 +1,83 @@ +package com.alibaba.cloud.ai.observation.model.semconv; + +import static org.springframework.ai.chat.messages.MessageType.ASSISTANT; +import static org.springframework.ai.chat.messages.MessageType.SYSTEM; +import static org.springframework.ai.chat.messages.MessageType.TOOL; +import static org.springframework.ai.chat.messages.MessageType.USER; + +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputModel.ChatMessage; +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputModel.MessagePart; +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputModel.OutputMessage; +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputModel.RoleEnum; +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputModel.TextPart; +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputModel.ToolCallRequestPart; +import com.alibaba.cloud.ai.observation.model.semconv.InputOutputModel.ToolCallResponsePart; +import java.util.ArrayList; +import java.util.List; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.AssistantMessage.ToolCall; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.util.StringUtils; + +public final class InputOutputUtils { + + public static ChatMessage convertFromMessage(Message message) { + String role = getRole(message.getMessageType()); + List messageParts = new ArrayList<>(); + if (StringUtils.hasText(message.getText())) { + messageParts.add(new TextPart("text", message.getText())); + } + if (message instanceof AssistantMessage && ((AssistantMessage) message).hasToolCalls()) { + for (ToolCall toolCall : ((AssistantMessage) message).getToolCalls()) { + messageParts + .add(new ToolCallRequestPart("tool_call", toolCall.id(), toolCall.name(), toolCall.arguments())); + } + } + else if (message instanceof ToolResponseMessage && !((ToolResponseMessage) message).getResponses().isEmpty()) { + for (ToolResponse response : ((ToolResponseMessage) message).getResponses()) { + messageParts + .add(new ToolCallResponsePart("tool_call_response", response.id(), response.responseData())); + } + } + return new ChatMessage(role, messageParts); + } + + public static OutputMessage convertFromGeneration(Generation generation) { + String finishReason = generation.getMetadata().getFinishReason(); + String role = getRole(generation.getOutput().getMessageType()); + List messageParts = new ArrayList<>(); + if (generation.getOutput().hasToolCalls()) { + for (ToolCall toolCall : generation.getOutput().getToolCalls()) { + messageParts + .add(new ToolCallRequestPart("tool_call", toolCall.id(), toolCall.name(), toolCall.arguments())); + } + } + if (StringUtils.hasText(generation.getOutput().getText())) { + messageParts.add(new TextPart("text", generation.getOutput().getText())); + } + return new OutputMessage(role, messageParts, finishReason); + } + + private static String getRole(MessageType messageType) { + if (messageType == USER) { + return RoleEnum.USER.value; + } + else if (messageType == TOOL) { + return RoleEnum.TOOL.value; + } + else if (messageType == ASSISTANT) { + return RoleEnum.ASSISTANT.value; + } + else if (messageType == SYSTEM) { + return RoleEnum.SYSTEM.value; + } + else { + return RoleEnum.UNKNOWN.value; + } + } + +} diff --git a/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/MessageMode.java b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/MessageMode.java new file mode 100644 index 0000000000..97acc80ecf --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/java/com/alibaba/cloud/ai/observation/model/semconv/MessageMode.java @@ -0,0 +1,7 @@ +package com.alibaba.cloud.ai.observation.model.semconv; + +public enum MessageMode { + + OPEN_TELEMETRY, LANGFUSE; + +} diff --git a/spring-ai-alibaba-observation-extension/src/main/resources/application.properties b/spring-ai-alibaba-observation-extension/src/main/resources/application.properties new file mode 100644 index 0000000000..10c99feea1 --- /dev/null +++ b/spring-ai-alibaba-observation-extension/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=spring-ai-alibaba-observation-extension