Add ObservationRegistry support to ChatClient

- Implement observable chat responses in DefaultChatClient
 - Add ChatClientObservationContext and related classes for metrics
 - Update ChatClient and builder methods to support ObservationRegistry
 - Enhance RequestResponseAdvisor with getName() method
 - Add ChatClient streaming observability support
 - Introduce ChatClientObservationDocumentation for metric key names
 - Create DefaultChatClientObservationConvention for implementing conventions
 - Add ChatClientInputContentObservationFilter for optional input content logging
 - Update ChatClientAutoConfiguration to include new observation components
 - Extend ChatClientBuilderProperties with observation configuration options
 - Add unit tests for new observation classes and configurations
 - Update AiOperationType and AiProvider enums with new values
 - Implement safeguards and warnings for sensitive data in observations

 Resolves #1206
This commit is contained in:
Christian Tzolov
2024-08-06 18:28:52 +02:00
parent bf84d5945e
commit 66e4b8868f
17 changed files with 1035 additions and 23 deletions

View File

@@ -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();

View File

@@ -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 <T> ResponseEntity<ChatResponse, T> doResponseEntity(StructuredOutputConverter<T> 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> T doSingleWithBeanOutputConverter(StructuredOutputConverter<T> 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<ChatResponse> 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<ChatResponse> doGetFluxChatResponse2(DefaultChatClientRequestSpec inputRequest) {
Map<String, Object> 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<String, Object> 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<String, Object> userParams,
String systemText, Map<String, Object> systemParams, List<FunctionCallback> functionCallbacks,
List<Message> messages, List<String> functionNames, List<Media> media, ChatOptions chatOptions,
List<RequestResponseAdvisor> advisors, Map<String, Object> advisorParams) {
List<RequestResponseAdvisor> advisors, Map<String, Object> 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;

View File

@@ -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() {

View File

@@ -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}.

View File

@@ -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(",", "[", "]")));
}
}

View File

@@ -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;
}
}

View File

@@ -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<ChatClientObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof ChatClientObservationContext;
}
}

View File

@@ -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<? extends ObservationConvention<? extends Observation.Context>> 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";
}
};
}
}

View File

@@ -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(",", "[", "]")));
}
}

View File

@@ -31,6 +31,7 @@ public enum AiOperationType {
CHAT("chat"),
EMBEDDING("embedding"),
FRAMEWORK("framework"),
IMAGE("image"),
TEXT_COMPLETION("text_completion");

View File

@@ -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;

View File

@@ -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\"]"));
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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}.
* <p>
@@ -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<ChatClientCustomizer> 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> observationRegistry,
ObjectProvider<ChatClientObservationConvention> 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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
});
}
}