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