diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java index ae18411b6..418f532f6 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java @@ -21,17 +21,19 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; -import org.springframework.ai.model.Media; +import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.converter.StructuredOutputConverter; +import org.springframework.ai.model.Media; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.Resource; import org.springframework.util.MimeType; +import io.micrometer.observation.ObservationRegistry; import reactor.core.publisher.Flux; /** @@ -48,11 +50,25 @@ import reactor.core.publisher.Flux; public interface ChatClient { static ChatClient create(ChatModel chatModel) { - return builder(chatModel).build(); + return create(chatModel, ObservationRegistry.NOOP); + } + + static ChatClient create(ChatModel chatModel, ObservationRegistry observationRegistry) { + return create(chatModel, observationRegistry, null); + } + + static ChatClient create(ChatModel chatModel, ObservationRegistry observationRegistry, + ChatClientObservationConvention observationConvention) { + return builder(chatModel, observationRegistry, observationConvention).build(); } static Builder builder(ChatModel chatModel) { - return new DefaultChatClientBuilder(chatModel); + return builder(chatModel, ObservationRegistry.NOOP, null); + } + + static Builder builder(ChatModel chatModel, ObservationRegistry observationRegistry, + ChatClientObservationConvention customObservationConvention) { + return new DefaultChatClientBuilder(chatModel, observationRegistry, customObservationConvention); } ChatClientRequestSpec prompt(); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java index 3f398fe68..74c6853f7 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java @@ -28,9 +28,10 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; -import reactor.core.publisher.Flux; - -import org.springframework.ai.model.Media; +import org.springframework.ai.chat.client.observation.ChatClientObservationContext; +import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; +import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation; +import org.springframework.ai.chat.client.observation.DefaultChatClientObservationConvention; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; @@ -42,6 +43,7 @@ import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.converter.StructuredOutputConverter; +import org.springframework.ai.model.Media; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallbackWrapper; import org.springframework.ai.model.function.FunctionCallingOptions; @@ -52,6 +54,11 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; import org.springframework.util.StringUtils; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; +import reactor.core.publisher.Flux; + /** * The default implementation of {@link ChatClient} as created by the * {@link Builder#build()} } method. @@ -65,6 +72,8 @@ import org.springframework.util.StringUtils; */ public class DefaultChatClient implements ChatClient { + private static final ChatClientObservationConvention DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION = new DefaultChatClientObservationConvention(); + private final ChatModel chatModel; private final DefaultChatClientRequestSpec defaultChatClientRequest; @@ -281,7 +290,7 @@ public class DefaultChatClient implements ChatClient { } protected ResponseEntity doResponseEntity(StructuredOutputConverter boc) { - var chatResponse = doGetChatResponse(this.request, boc.getFormat()); + var chatResponse = doGetObservableChatResponse(this.request, boc.getFormat()); var responseContent = chatResponse.getResult().getOutput().getContent(); T entity = boc.convert(responseContent); @@ -297,7 +306,7 @@ public class DefaultChatClient implements ChatClient { } private T doSingleWithBeanOutputConverter(StructuredOutputConverter boc) { - var chatResponse = doGetChatResponse(this.request, boc.getFormat()); + var chatResponse = doGetObservableChatResponse(this.request, boc.getFormat()); var stringResponse = chatResponse.getResult().getOutput().getContent(); return boc.convert(stringResponse); } @@ -309,7 +318,23 @@ public class DefaultChatClient implements ChatClient { } private ChatResponse doGetChatResponse() { - return this.doGetChatResponse(this.request, ""); + return this.doGetObservableChatResponse(this.request, ""); + } + + private ChatResponse doGetObservableChatResponse(DefaultChatClientRequestSpec inputRequest, + String formatParam) { + + ChatClientObservationContext observationContext = new ChatClientObservationContext(inputRequest, + formatParam, false); + + return ChatClientObservationDocumentation.AI_CHAT_CLIENT + .observation(inputRequest.customObservationConvention, DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION, + () -> observationContext, inputRequest.observationRegistry) + .observe(() -> { + ChatResponse chatResponse = doGetChatResponse(inputRequest, formatParam); + return chatResponse; + }); + } private ChatResponse doGetChatResponse(DefaultChatClientRequestSpec inputRequest, String formatParam) { @@ -395,6 +420,29 @@ public class DefaultChatClient implements ChatClient { } private Flux doGetFluxChatResponse(DefaultChatClientRequestSpec inputRequest) { + return Flux.deferContextual(contextView -> { + ChatClientObservationContext observationContext = new ChatClientObservationContext(inputRequest, "", + true); + + Observation observation = ChatClientObservationDocumentation.AI_CHAT_CLIENT.observation( + inputRequest.customObservationConvention, DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION, + () -> observationContext, inputRequest.observationRegistry); + + observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)) + .start(); + + // @formatter:off + return doGetFluxChatResponse2(inputRequest) + .doOnError(observation::error) + .doFinally(s -> { + observation.stop(); + }) + .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); + // @formatter:on + }); + } + + private Flux doGetFluxChatResponse2(DefaultChatClientRequestSpec inputRequest) { Map context = new ConcurrentHashMap<>(); context.putAll(inputRequest.getAdvisorParams()); @@ -426,9 +474,7 @@ public class DefaultChatClient implements ChatClient { messages.add(userMessage); } - if (advisedRequest.getChatOptions() instanceof - - FunctionCallingOptions functionCallingOptions) { + if (advisedRequest.getChatOptions() instanceof FunctionCallingOptions functionCallingOptions) { if (!advisedRequest.getFunctionNames().isEmpty()) { functionCallingOptions.setFunctions(new HashSet<>(advisedRequest.getFunctionNames())); } @@ -470,6 +516,10 @@ public class DefaultChatClient implements ChatClient { public static class DefaultChatClientRequestSpec implements ChatClientRequestSpec { + private final ObservationRegistry observationRegistry; + + private final ChatClientObservationConvention customObservationConvention; + private final ChatModel chatModel; private String userText = ""; @@ -494,6 +544,14 @@ public class DefaultChatClient implements ChatClient { private final Map advisorParams = new HashMap<>(); + private ObservationRegistry getObservationRegistry() { + return observationRegistry; + } + + private ChatClientObservationConvention getCustomObservationConvention() { + return customObservationConvention; + } + public String getUserText() { return userText; } @@ -541,13 +599,15 @@ public class DefaultChatClient implements ChatClient { /* copy constructor */ DefaultChatClientRequestSpec(DefaultChatClientRequestSpec ccr) { this(ccr.chatModel, ccr.userText, ccr.userParams, ccr.systemText, ccr.systemParams, ccr.functionCallbacks, - ccr.messages, ccr.functionNames, ccr.media, ccr.chatOptions, ccr.advisors, ccr.advisorParams); + ccr.messages, ccr.functionNames, ccr.media, ccr.chatOptions, ccr.advisors, ccr.advisorParams, + ccr.observationRegistry, ccr.customObservationConvention); } public DefaultChatClientRequestSpec(ChatModel chatModel, String userText, Map userParams, String systemText, Map systemParams, List functionCallbacks, List messages, List functionNames, List media, ChatOptions chatOptions, - List advisors, Map advisorParams) { + List advisors, Map advisorParams, + ObservationRegistry observationRegistry, ChatClientObservationConvention customObservationConvention) { this.chatModel = chatModel; this.chatOptions = chatOptions != null ? chatOptions.copy() @@ -564,6 +624,8 @@ public class DefaultChatClient implements ChatClient { this.media.addAll(media); this.advisors.addAll(advisors); this.advisorParams.putAll(advisorParams); + this.observationRegistry = observationRegistry; + this.customObservationConvention = customObservationConvention; } /** @@ -571,7 +633,8 @@ public class DefaultChatClient implements ChatClient { * settings are replicated from this {@code ChatClientRequest}. */ public Builder mutate() { - DefaultChatClientBuilder builder = (DefaultChatClientBuilder) ChatClient.builder(chatModel) + DefaultChatClientBuilder builder = (DefaultChatClientBuilder) ChatClient + .builder(chatModel, this.observationRegistry, this.customObservationConvention) .defaultSystem(s -> s.text(this.systemText).params(this.systemParams)) .defaultUser(u -> u.text(this.userText) .params(this.userParams) @@ -756,7 +819,8 @@ public class DefaultChatClient implements ChatClient { adviseRequest.userParams(), adviseRequest.systemText(), adviseRequest.systemParams(), adviseRequest.functionCallbacks(), adviseRequest.messages(), adviseRequest.functionNames(), adviseRequest.media(), adviseRequest.chatOptions(), adviseRequest.advisors(), - adviseRequest.advisorParams()); + adviseRequest.advisorParams(), inputRequest.getObservationRegistry(), + inputRequest.getCustomObservationConvention()); } return advisedRequest; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java index d059c2bd3..cd9c7a9fa 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java @@ -26,11 +26,14 @@ import org.springframework.ai.chat.client.ChatClient.Builder; import org.springframework.ai.chat.client.ChatClient.PromptSystemSpec; import org.springframework.ai.chat.client.ChatClient.PromptUserSpec; import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec; +import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.core.io.Resource; import org.springframework.util.Assert; +import io.micrometer.observation.ObservationRegistry; + /** * DefaultChatClientBuilder is a builder class for creating a ChatClient. * @@ -48,11 +51,18 @@ public class DefaultChatClientBuilder implements Builder { private final ChatModel chatModel; - public DefaultChatClientBuilder(ChatModel chatModel) { + DefaultChatClientBuilder(ChatModel chatModel) { + this(chatModel, ObservationRegistry.NOOP, null); + } + + public DefaultChatClientBuilder(ChatModel chatModel, ObservationRegistry observationRegistry, + ChatClientObservationConvention customObservationConvention) { Assert.notNull(chatModel, "the " + ChatModel.class.getName() + " must be non-null"); + Assert.notNull(observationRegistry, "the " + ObservationRegistry.class.getName() + " must be non-null"); this.chatModel = chatModel; this.defaultRequest = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(), List.of(), - List.of(), List.of(), List.of(), null, List.of(), Map.of()); + List.of(), List.of(), List.of(), null, List.of(), Map.of(), observationRegistry, + customObservationConvention); } public ChatClient build() { diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/RequestResponseAdvisor.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/RequestResponseAdvisor.java index 2d6c5bc18..a696c66c6 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/RequestResponseAdvisor.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/RequestResponseAdvisor.java @@ -34,6 +34,13 @@ import org.springframework.ai.chat.prompt.Prompt; */ public interface RequestResponseAdvisor { + /** + * @return the advisor name. + */ + default String getName() { + return this.getClass().getSimpleName(); + } + /** * @param request the {@link AdvisedRequest} data to be advised. Represents the row * {@link ChatClient.ChatClientRequestSpec} data before sealed into a {@link Prompt}. diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilter.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilter.java new file mode 100644 index 000000000..f250d4c0e --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilter.java @@ -0,0 +1,103 @@ +/* + * 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.client.observation; + +import java.util.stream.Collectors; + +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationFilter; + +/** + * An {@link ObservationFilter} to include the chat prompt content in the observation. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +public class ChatClientInputContentObservationFilter implements ObservationFilter { + + private static final KeyValue CHAT_CLIENT_SYSTEM_TEXT_NONE = KeyValue + .of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_TEXT, KeyValue.NONE_VALUE); + + private static final KeyValue CHAT_CLIENT_SYSTEM_PARAM_NONE = KeyValue + .of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_PARAM, KeyValue.NONE_VALUE); + + private static final KeyValue CHAT_CLIENT_USER_TEXT_NONE = KeyValue + .of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_TEXT, KeyValue.NONE_VALUE); + + private static final KeyValue CHAT_CLIENT_USER_PARAM_NONE = KeyValue + .of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_PARAMS, KeyValue.NONE_VALUE); + + @Override + public Observation.Context map(Observation.Context context) { + if (!(context instanceof ChatClientObservationContext chatClientObservationContext)) { + return context; + } + + chatClientObservationContext.addHighCardinalityKeyValue(chatClientSystemText(chatClientObservationContext)) + .addHighCardinalityKeyValue(chatClientSystemParam(chatClientObservationContext)) + .addHighCardinalityKeyValue(chatClientUserText(chatClientObservationContext)) + .addHighCardinalityKeyValue(chatClientUserParam(chatClientObservationContext)); + + return chatClientObservationContext; + } + + protected KeyValue chatClientSystemText(ChatClientObservationContext context) { + if (!StringUtils.hasText(context.getRequest().getUserText())) { + return CHAT_CLIENT_SYSTEM_TEXT_NONE; + } + return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_TEXT, + context.getRequest().getSystemText()); + } + + protected KeyValue chatClientSystemParam(ChatClientObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().getSystemParams())) { + return CHAT_CLIENT_SYSTEM_PARAM_NONE; + } + return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_PARAM, + context.getRequest() + .getSystemParams() + .entrySet() + .stream() + .map(e -> "\"" + e.getKey() + "\":\"" + e.getValue() + "\"") + .collect(Collectors.joining(",", "[", "]"))); + } + + protected KeyValue chatClientUserText(ChatClientObservationContext context) { + if (!StringUtils.hasText(context.getRequest().getUserText())) { + return CHAT_CLIENT_USER_TEXT_NONE; + } + return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_TEXT, + context.getRequest().getUserText()); + } + + protected KeyValue chatClientUserParam(ChatClientObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().getUserParams())) { + return CHAT_CLIENT_USER_PARAM_NONE; + } + return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_PARAMS, + context.getRequest() + .getUserParams() + .entrySet() + .stream() + .map(e -> "\"" + e.getKey() + "\":\"" + e.getValue() + "\"") + .collect(Collectors.joining(",", "[", "]"))); + } + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java new file mode 100644 index 000000000..cb5bf80da --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java @@ -0,0 +1,64 @@ +/* +* Copyright 2024 - 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.client.observation; + +import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec; +import org.springframework.ai.observation.AiOperationMetadata; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; + +import io.micrometer.observation.Observation; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public class ChatClientObservationContext extends Observation.Context { + + private final boolean stream; + + private final String format; + + private final DefaultChatClientRequestSpec request; + + private final AiOperationMetadata operationMetadata = new AiOperationMetadata(AiOperationType.FRAMEWORK.value(), + AiProvider.SPRING_AI.value()); + + public ChatClientObservationContext(DefaultChatClientRequestSpec requestSpec, String format, Boolean isStream) { + + this.request = requestSpec; + this.format = format; + this.stream = isStream; + } + + public DefaultChatClientRequestSpec getRequest() { + return this.request; + } + + public AiOperationMetadata getOperationMetadata() { + return this.operationMetadata; + } + + public boolean isStream() { + return this.stream; + } + + public String getFormat() { + return this.format; + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationConvention.java new file mode 100644 index 000000000..fd6790c48 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationConvention.java @@ -0,0 +1,33 @@ +/* +* Copyright 2024 - 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.client.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public interface ChatClientObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof ChatClientObservationContext; + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java new file mode 100644 index 000000000..875832201 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationDocumentation.java @@ -0,0 +1,152 @@ +/* +* Copyright 2024 - 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.client.observation; + +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +public enum ChatClientObservationDocumentation implements ObservationDocumentation { + + /** + * AI Chat Client observations + */ + AI_CHAT_CLIENT { + @Override + public Class> getDefaultConvention() { + return DefaultChatClientObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + + }; + + public enum LowCardinalityKeyNames implements KeyName { + + /** + * Spring AI kind. + */ + SPRING_AI_KIND { + @Override + public String asString() { + return "spring.ai.kind"; + } + }, + + /** + * Is the chat model response a stream. + */ + STREAM { + @Override + public String asString() { + return "spring.ai.chat.client.stream"; + } + } + + } + + public enum HighCardinalityKeyNames implements KeyName { + + /** + * Enabled tool function names. + */ + CHAT_CLIENT_TOOL_FUNCTION_NAMES { + @Override + public String asString() { + return "spring.ai.chat.client.tool.function.names"; + } + }, + /** + * List of configured chat client function callbacks. + */ + CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS { + @Override + public String asString() { + return "spring.ai.chat.client.tool.functioncallbacks"; + } + }, + + /** + * List of configured chat client advisors. + */ + CHAT_CLIENT_ADVISORS { + @Override + public String asString() { + return "spring.ai.chat.client.advisors"; + } + }, + /** + * Map of advisor parameters. + */ + CHAT_CLIENT_ADVISOR_PARAMS { + @Override + public String asString() { + return "spring.ai.chat.client.advisor.params"; + } + }, + /** + * Chat client user text. + */ + CHAT_CLIENT_USER_TEXT { + @Override + public String asString() { + return "spring.ai.chat.client.user.text"; + } + }, + /** + * Chat client user parameters. + */ + CHAT_CLIENT_USER_PARAMS { + @Override + public String asString() { + return "spring.ai.chat.client.user.params"; + } + }, + /** + * Chat client system text. + */ + CHAT_CLIENT_SYSTEM_TEXT { + @Override + public String asString() { + return "spring.ai.chat.client.system.text"; + } + }, + /** + * Chat client system parameters. + */ + CHAT_CLIENT_SYSTEM_PARAM { + @Override + public String asString() { + return "spring.ai.chat.client.system.params"; + } + }; + + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java new file mode 100644 index 000000000..f0f30cce8 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConvention.java @@ -0,0 +1,155 @@ +/* +* Copyright 2024 - 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.client.observation; + +import java.util.stream.Collectors; + +import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.LowCardinalityKeyNames; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public class DefaultChatClientObservationConvention implements ChatClientObservationConvention { + + public static final String DEFAULT_NAME = "spring.ai.chat.client.operation"; + + private static final String CHAT_CLIENT_SPRING_AI_KIND = "chat_client"; + + private static final KeyValue CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS_NONE = KeyValue.of( + ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS, + KeyValue.NONE_VALUE); + + private static final KeyValue CHAT_CLIENT_TOOL_FUNCTION_NAMES_NONE = KeyValue.of( + ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES, + KeyValue.NONE_VALUE); + + private static final KeyValue CHAT_CLIENT_ADVISOR_NONE = KeyValue + .of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS, KeyValue.NONE_VALUE); + + private static final KeyValue CHAT_CLIENT_ADVISOR_PARAM_NONE = KeyValue + .of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS, KeyValue.NONE_VALUE); + + private final String name; + + public DefaultChatClientObservationConvention() { + this(DEFAULT_NAME); + } + + public DefaultChatClientObservationConvention(String name) { + this.name = name; + } + + @Override + public String getName() { + return this.name; + } + + @Override + @Nullable + public String getContextualName(ChatClientObservationContext context) { + return "%s %s".formatted(context.getOperationMetadata().provider(), CHAT_CLIENT_SPRING_AI_KIND); + } + + @Override + public KeyValues getLowCardinalityKeyValues(ChatClientObservationContext context) { + return KeyValues.of(springAiKind(context), aiOperationType(context), aiProvider(context), stream(context)); + } + + @Override + public KeyValues getHighCardinalityKeyValues(ChatClientObservationContext context) { + return KeyValues.of(toolFunctionNames(context), toolFunctionCallbacks(context), chatClientAvisor(context), + chatClientAvisorParam(context)); + } + + protected KeyValue springAiKind(ChatClientObservationContext context) { + return KeyValue.of(ChatClientObservationDocumentation.LowCardinalityKeyNames.SPRING_AI_KIND, + CHAT_CLIENT_SPRING_AI_KIND); + } + + protected KeyValue stream(ChatClientObservationContext context) { + return KeyValue.of(LowCardinalityKeyNames.STREAM, "" + context.isStream()); + } + + protected KeyValue aiOperationType(ChatClientObservationContext context) { + return KeyValue.of(ChatModelObservationDocumentation.LowCardinalityKeyNames.AI_OPERATION_TYPE, + context.getOperationMetadata().operationType()); + } + + protected KeyValue aiProvider(ChatClientObservationContext context) { + return KeyValue.of(ChatModelObservationDocumentation.LowCardinalityKeyNames.AI_PROVIDER, + context.getOperationMetadata().provider()); + } + + protected KeyValue toolFunctionNames(ChatClientObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().getFunctionNames())) { + return CHAT_CLIENT_TOOL_FUNCTION_NAMES_NONE; + } + return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES, + context.getRequest() + .getFunctionNames() + .stream() + .map(v -> "\"" + v + "\"") + .collect(Collectors.joining(",", "[", "]"))); + } + + protected KeyValue toolFunctionCallbacks(ChatClientObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().getFunctionCallbacks())) { + return CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS_NONE; + } + + return KeyValue.of( + ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS, + context.getRequest() + .getFunctionCallbacks() + .stream() + .map(fc -> "\"" + fc.getName() + "\"") + .collect(Collectors.joining(",", "[", "]"))); + } + + protected KeyValue chatClientAvisor(ChatClientObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().getAdvisors())) { + return CHAT_CLIENT_ADVISOR_NONE; + } + return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS, + context.getRequest() + .getAdvisors() + .stream() + .map(a -> "\"" + a.getName() + "\"") + .collect(Collectors.joining(",", "[", "]"))); + } + + protected KeyValue chatClientAvisorParam(ChatClientObservationContext context) { + if (CollectionUtils.isEmpty(context.getRequest().getAdvisorParams())) { + return CHAT_CLIENT_ADVISOR_PARAM_NONE; + } + return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS, + context.getRequest() + .getAdvisorParams() + .entrySet() + .stream() + .map(e -> "\"" + e.getKey() + "\":\"" + e.getValue() + "\"") + .collect(Collectors.joining(",", "[", "]"))); + } + +} \ No newline at end of file diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiOperationType.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiOperationType.java index 45ea85671..7d6fe52ff 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiOperationType.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiOperationType.java @@ -31,6 +31,7 @@ public enum AiOperationType { CHAT("chat"), EMBEDDING("embedding"), + FRAMEWORK("framework"), IMAGE("image"), TEXT_COMPLETION("text_completion"); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java index 01f678f02..e3da38c62 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java @@ -33,6 +33,7 @@ public enum AiProvider { MISTRAL_AI("mistral_ai"), OLLAMA("ollama"), OPENAI("openai"), + SPRING_AI("spring_ai"), VERTEX_AI("vertex_ai"); private final String value; diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilterTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilterTests.java new file mode 100644 index 000000000..586a11fa1 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/observation/ChatClientInputContentObservationFilterTests.java @@ -0,0 +1,96 @@ +/* + * 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.client.observation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec; +import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.HighCardinalityKeyNames; +import org.springframework.ai.chat.model.ChatModel; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + +/** + * Unit tests for {@link ChatClientImportContentObservationFilter}. + * + * @author Christian Tzolov + */ +@ExtendWith(MockitoExtension.class) +class ChatClientInputContentObservationFilterTests { + + private final ChatClientInputContentObservationFilter observationFilter = new ChatClientInputContentObservationFilter(); + + @Test + void whenNotSupportedObservationContextThenReturnOriginalContext() { + var expectedContext = new Observation.Context(); + var actualContext = observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Mock + ChatModel chatModel; + + @Test + void whenEmptyInputContentThenReturnOriginalContext() { + + ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + ChatClientObservationConvention customObservationConvention = null; + + var request = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(), List.of(), List.of(), + List.of(), List.of(), null, List.of(), Map.of(), observationRegistry, customObservationConvention); + + var expectedContext = new ChatClientObservationContext(request, "", false); + + var actualContext = observationFilter.map(expectedContext); + + assertThat(actualContext).isEqualTo(expectedContext); + } + + @Test + void whenWithTextThenAugmentContext() { + + ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + ChatClientObservationConvention customObservationConvention = null; + + var request = new DefaultChatClientRequestSpec(chatModel, "sample user text", Map.of("up1", "upv1"), + "sample system text", Map.of("sp1", "sp1v"), List.of(), List.of(), List.of(), List.of(), null, + List.of(), Map.of(), observationRegistry, customObservationConvention); + + var originalContext = new ChatClientObservationContext(request, "", false); + + var augmentedContext = observationFilter.map(originalContext); + + assertThat(augmentedContext.getHighCardinalityKeyValues()) + .contains(KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_USER_TEXT.asString(), "sample user text")); + assertThat(augmentedContext.getHighCardinalityKeyValues()) + .contains(KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_USER_PARAMS.asString(), "[\"up1\":\"upv1\"]")); + assertThat(augmentedContext.getHighCardinalityKeyValues()) + .contains(KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_TEXT.asString(), "sample system text")); + assertThat(augmentedContext.getHighCardinalityKeyValues()) + .contains(KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_PARAM.asString(), "[\"sp1\":\"sp1v\"]")); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/observation/ChatClientObservationContextTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/observation/ChatClientObservationContextTests.java new file mode 100644 index 000000000..94d74efdc --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/observation/ChatClientObservationContextTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.ai.chat.client.observation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec; +import org.springframework.ai.chat.model.ChatModel; + +import io.micrometer.observation.ObservationRegistry; + +/** + * Unit tests for {@link ChatClientObservationContext}. + * + * @author Christian Tzolov + */ +@ExtendWith(MockitoExtension.class) +class ChatClientObservationContextTests { + + @Mock + ChatModel chatModel; + + @Test + void whenMandatoryRequestOptionsThenReturn() { + + var request = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(), List.of(), List.of(), + List.of(), List.of(), null, List.of(), Map.of(), ObservationRegistry.NOOP, null); + + var observationContext = new ChatClientObservationContext(request, "", true); + + assertThat(observationContext).isNotNull(); + } + +} diff --git a/spring-ai-core/src/test/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConventionTests.java b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConventionTests.java new file mode 100644 index 000000000..27e9a12d0 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/chat/client/observation/DefaultChatClientObservationConventionTests.java @@ -0,0 +1,161 @@ +/* + * 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.client.observation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec; +import org.springframework.ai.chat.client.RequestResponseAdvisor; +import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.HighCardinalityKeyNames; +import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.LowCardinalityKeyNames; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.model.function.FunctionCallback; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + +/** + * Unit tests for {@link DefaultChatClientObservationConvention}. + * + * @author Christian Tzolov + */ +@ExtendWith(MockitoExtension.class) +class DefaultChatClientObservationConventionTests { + + @Mock + ChatModel chatModel; + + private final DefaultChatClientObservationConvention observationConvention = new DefaultChatClientObservationConvention(); + + DefaultChatClientRequestSpec request; + + @BeforeEach + public void beforeEach() { + request = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(), List.of(), List.of(), + List.of(), List.of(), null, List.of(), Map.of(), ObservationRegistry.NOOP, null); + } + + @Test + void shouldHaveName() { + assertThat(this.observationConvention.getName()).isEqualTo(DefaultChatClientObservationConvention.DEFAULT_NAME); + } + + @Test + void shouldHaveContextualName() { + ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "", true); + + assertThat(this.observationConvention.getContextualName(observationContext)).isEqualTo("spring_ai chat_client"); + } + + @Test + void supportsOnlyChatClientObservationContext() { + ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "", true); + + assertThat(this.observationConvention.supportsContext(observationContext)).isTrue(); + assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse(); + } + + @Test + void shouldHaveRequiredKeyValues() { + ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "", true); + + assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains( + KeyValue.of(LowCardinalityKeyNames.SPRING_AI_KIND.asString(), "chat_client"), + KeyValue.of(LowCardinalityKeyNames.STREAM.asString(), "true")); + } + + static RequestResponseAdvisor dummyAdvisor(String name) { + return new RequestResponseAdvisor() { + @Override + public String getName() { + return name; + } + }; + } + + static FunctionCallback dummyFunction(String name) { + return new FunctionCallback() { + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getDescription'"); + } + + @Override + public String getInputTypeSchema() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getInputTypeSchema'"); + } + + @Override + public String call(String functionInput) { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'call'"); + } + }; + } + + @Test + void shouldHaveOptionalKeyValues() { + + var request = new DefaultChatClientRequestSpec(chatModel, "", Map.of(), "", Map.of(), + List.of(dummyFunction("functionCallback1"), dummyFunction("functionCallback2")), List.of(), + List.of("function1", "function2"), List.of(), null, + List.of(dummyAdvisor("advisor1"), dummyAdvisor("advisor2")), Map.of("advParam1", "advisorParam1Value"), + ObservationRegistry.NOOP, null); + + ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "json", true); + + assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains( + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(), "[\"advisor1\",\"advisor2\"]"), + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS.asString(), + "[\"advParam1\":\"advisorParam1Value\"]"), + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES.asString(), + "[\"function1\",\"function2\"]"), + KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS.asString(), + "[\"functionCallback1\",\"functionCallback2\"]")); + } + + static class TestUsage implements Usage { + + @Override + public Long getPromptTokens() { + return 1000L; + } + + @Override + public Long getGenerationTokens() { + return 500L; + } + + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java index 7069111ad..9e5a9185f 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientAutoConfiguration.java @@ -16,9 +16,13 @@ package org.springframework.ai.autoconfigure.chat.client; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter; +import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; +import org.springframework.ai.chat.model.ChatModel; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -29,6 +33,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Scope; +import io.micrometer.observation.ObservationRegistry; + /** * {@link EnableAutoConfiguration Auto-configuration} for {@link ChatClient}. *

@@ -49,6 +55,8 @@ import org.springframework.context.annotation.Scope; matchIfMissing = true) public class ChatClientAutoConfiguration { + private static final Logger logger = LoggerFactory.getLogger(ChatClientAutoConfiguration.class); + @Bean @ConditionalOnMissingBean ChatClientBuilderConfigurer chatClientBuilderConfigurer(ObjectProvider customizerProvider) { @@ -59,9 +67,24 @@ public class ChatClientAutoConfiguration { @Bean @Scope("prototype") - ChatClient.Builder chatClientBuilder(ChatClientBuilderConfigurer chatClientBuilderConfigurer, ChatModel chatModel) { - ChatClient.Builder builder = ChatClient.builder(chatModel); + ChatClient.Builder chatClientBuilder(ChatClientBuilderConfigurer chatClientBuilderConfigurer, ChatModel chatModel, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + ChatClient.Builder builder = ChatClient.builder(chatModel, + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP), + observationConvention.getIfUnique(() -> null)); return chatClientBuilderConfigurer.configure(builder); } + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = ChatClientBuilderProperties.CONFIG_PREFIX + ".observations", name = "include-input", + havingValue = "true") + ChatClientInputContentObservationFilter chatClientInputContentObservationFilter() { + logger.warn( + "You have enabled the inclusion of the input content in the observations, with the risk of exposing sensitive or private information. Please, be careful!"); + return new ChatClientInputContentObservationFilter(); + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java index 329c29f3e..102c5b745 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/chat/client/ChatClientBuilderProperties.java @@ -24,7 +24,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * @author Mark Pollack * @author Josh Long * @author Arjen Poutsma - * @since 1.0.0 M1 + * @since 1.0.0 */ @ConfigurationProperties(ChatClientBuilderProperties.CONFIG_PREFIX) public class ChatClientBuilderProperties { @@ -36,12 +36,35 @@ public class ChatClientBuilderProperties { */ private boolean enabled = true; + private Observations observations = new Observations(); + + public Observations getObservations() { + return this.observations; + } + public boolean isEnabled() { - return enabled; + return this.enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } + public static class Observations { + + /** + * Whether to include the input content in the observations. + */ + private boolean includeInput = false; + + public boolean isIncludeInput() { + return includeInput; + } + + public void setIncludeInput(boolean includeCompletion) { + this.includeInput = includeCompletion; + } + + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientObservationAutoConfigurationTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientObservationAutoConfigurationTests.java new file mode 100644 index 000000000..94a7ec0db --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/chat/client/ChatClientObservationAutoConfigurationTests.java @@ -0,0 +1,49 @@ +/* + * 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.autoconfigure.chat.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.observation.ChatClientInputContentObservationFilter; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Unit tests for {@link ChatClientAutoConfiguration} observability support. + * + * @author Christian Tzolov + */ +class ChatClientObservationAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ChatClientAutoConfiguration.class)); + + @Test + void inputContentFilterDefault() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(ChatClientInputContentObservationFilter.class); + }); + } + + @Test + void inputContentFilterEnabled() { + contextRunner.withPropertyValues("spring.ai.chat.client.observations.include-input=true").run(context -> { + assertThat(context).hasSingleBean(ChatClientInputContentObservationFilter.class); + }); + } + +}