Improve chat client and advisor observations

* Make ChatClient and Advisor observation logic null-safe
* Simplify naming for Advisor observations
* Include high-cardinality attributes only if a value is present
* Fix condition to include system test to chat client observations
* Add Advisor order information to context
* Streamline usage of enums and utils to reduce hard-coded/duplications
* Fix pending Chroma integration test

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2024-10-06 15:52:29 +02:00
committed by Christian Tzolov
parent 8ea56329ac
commit 1ee90944de
21 changed files with 310 additions and 223 deletions

View File

@@ -77,6 +77,7 @@ import reactor.core.scheduler.Schedulers;
* @author Arjen Poutsma
* @author Soby Chacko
* @author Dariusz Jedrzejczyk
* @author Thomas Vitale
* @since 1.0.0
*/
public class DefaultChatClient implements ChatClient {
@@ -360,8 +361,11 @@ public class DefaultChatClient implements ChatClient {
private ChatResponse doGetObservableChatResponse(DefaultChatClientRequestSpec inputRequest,
String formatParam) {
ChatClientObservationContext observationContext = new ChatClientObservationContext(inputRequest,
formatParam, false);
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
.withRequest(inputRequest)
.withFormat(formatParam)
.withStream(false)
.build();
var observation = ChatClientObservationDocumentation.AI_CHAT_CLIENT.observation(
inputRequest.getCustomObservationConvention(), DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION,
@@ -407,8 +411,10 @@ public class DefaultChatClient implements ChatClient {
private Flux<ChatResponse> doGetObservableFluxChatResponse(DefaultChatClientRequestSpec inputRequest) {
return Flux.deferContextual(contextView -> {
ChatClientObservationContext observationContext = new ChatClientObservationContext(inputRequest, "",
true);
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
.withRequest(inputRequest)
.withStream(true)
.build();
Observation observation = ChatClientObservationDocumentation.AI_CHAT_CLIENT.observation(
inputRequest.getCustomObservationConvention(), DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION,

View File

@@ -85,6 +85,7 @@ public class DefaultAroundAdvisorChain implements CallAroundAdvisorChain, Stream
.withAdvisorType(AdvisorObservationContext.Type.AROUND)
.withAdvisedRequest(advisedRequest)
.withAdvisorRequestContext(advisedRequest.adviseContext())
.withOrder(advisor.getOrder())
.build();
return AdvisorObservationDocumentation.AI_ADVISOR
@@ -106,6 +107,7 @@ public class DefaultAroundAdvisorChain implements CallAroundAdvisorChain, Stream
.withAdvisorType(AdvisorObservationContext.Type.AROUND)
.withAdvisedRequest(advisedRequest)
.withAdvisorRequestContext(advisedRequest.adviseContext())
.withOrder(advisor.getOrder())
.build();
var observation = AdvisorObservationDocumentation.AI_ADVISOR.observation(null,

View File

@@ -20,15 +20,18 @@ import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import io.micrometer.observation.Observation;
/**
* Context used to store metadata for chat client advisors.
*
* @author Christian Tzolov
* @author Thomas Vitale
* @since 1.0.0
*/
public class AdvisorObservationContext extends Observation.Context {
public enum Type {
@@ -37,35 +40,48 @@ public class AdvisorObservationContext extends Observation.Context {
}
private String advisorName;
private final String advisorName;
private Type advisorType;
private final Type advisorType;
/**
* The {@link AdvisedRequest} data to be advised. Represents the row
* {@link ChatClient.ChatClientRequestSpec} data before sealed into a {@link Prompt}.
*/
@Nullable
private AdvisedRequest advisorRequest;
/**
* The shared data between the advisors in the chain. It is shared between all request
* and response advising points of all advisors in the chain.
*/
@Nullable
private Map<String, Object> advisorRequestContext;
/**
* the shared data between the advisors in the chain. It is shared between all request
* and response advising points of all advisors in the chain.
*/
@Nullable
private Map<String, Object> advisorResponseContext;
/**
* The order of the advisor in the advisor chain.
*/
private int order;
private final int order;
public AdvisorObservationContext(String advisorName, Type advisorType, @Nullable AdvisedRequest advisorRequest,
@Nullable Map<String, Object> advisorRequestContext, @Nullable Map<String, Object> advisorResponseContext,
int order) {
Assert.hasText(advisorName, "advisorName must not be null or empty");
Assert.notNull(advisorType, "advisorType must not be null");
public void setAdvisorName(String advisorName) {
this.advisorName = advisorName;
this.advisorType = advisorType;
this.advisorRequest = advisorRequest;
this.advisorRequestContext = advisorRequestContext;
this.advisorResponseContext = advisorResponseContext;
this.order = order;
}
public String getAdvisorName() {
@@ -76,31 +92,30 @@ public class AdvisorObservationContext extends Observation.Context {
return this.advisorType;
}
public void setAdvisorType(Type type) {
this.advisorType = type;
}
@Nullable
public AdvisedRequest getAdvisedRequest() {
return this.advisorRequest;
}
public void setAdvisedRequest(AdvisedRequest advisedRequest) {
public void setAdvisedRequest(@Nullable AdvisedRequest advisedRequest) {
this.advisorRequest = advisedRequest;
}
@Nullable
public Map<String, Object> getAdvisorRequestContext() {
return this.advisorRequestContext;
}
public void setAdvisorRequestContext(Map<String, Object> advisorRequestContext) {
public void setAdvisorRequestContext(@Nullable Map<String, Object> advisorRequestContext) {
this.advisorRequestContext = advisorRequestContext;
}
@Nullable
public Map<String, Object> getAdvisorResponseContext() {
return this.advisorResponseContext;
}
public void setAdvisorResponseContext(Map<String, Object> advisorResponseContext) {
public void setAdvisorResponseContext(@Nullable Map<String, Object> advisorResponseContext) {
this.advisorResponseContext = advisorResponseContext;
}
@@ -108,52 +123,57 @@ public class AdvisorObservationContext extends Observation.Context {
return this.order;
}
public void setOrder(int order) {
this.order = order;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final AdvisorObservationContext context = new AdvisorObservationContext();
private String advisorName;
private Type advisorType;
private AdvisedRequest advisorRequest;
private Map<String, Object> advisorRequestContext;
private Map<String, Object> advisorResponseContext;
private int order = 0;
public Builder withAdvisorName(String advisorName) {
this.context.setAdvisorName(advisorName);
this.advisorName = advisorName;
return this;
}
public Builder withAdvisorType(Type advisorType) {
this.context.setAdvisorType(advisorType);
this.advisorType = advisorType;
return this;
}
public Builder withAdvisedRequest(AdvisedRequest advisedRequest) {
this.context.setAdvisedRequest(advisedRequest);
this.advisorRequest = advisedRequest;
return this;
}
public Builder withAdvisorRequestContext(Map<String, Object> advisorRequestContext) {
this.context.setAdvisorRequestContext(advisorRequestContext);
this.advisorRequestContext = advisorRequestContext;
return this;
}
public Builder withAdvisorResponseContext(Map<String, Object> advisorResponseContext) {
this.context.setAdvisorResponseContext(advisorResponseContext);
this.advisorResponseContext = advisorResponseContext;
return this;
}
public Builder withOrder(int order) {
this.context.setOrder(order);
this.order = order;
return this;
}
public AdvisorObservationContext build() {
Assert.hasText(this.context.advisorName, "The advisorName must not be empty!");
Assert.notNull(this.context.advisorType, "The advisorType must not be null!");
return this.context;
return new AdvisorObservationContext(advisorName, advisorType, advisorRequest, advisorRequestContext,
advisorResponseContext, order);
}
}

View File

@@ -19,6 +19,8 @@ import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
/**
* Interface for an {@link ObservationConvention} for chat client advisors.
*
* @author Christian Tzolov
* @since 1.0.0
*/

View File

@@ -27,7 +27,7 @@ import io.micrometer.observation.docs.ObservationDocumentation;
public enum AdvisorObservationDocumentation implements ObservationDocumentation {
/**
* AI Chat Client observations
* AI Advisor observations
*/
AI_ADVISOR {
@Override
@@ -65,7 +65,7 @@ public enum AdvisorObservationDocumentation implements ObservationDocumentation
ADVISOR_TYPE {
@Override
public String asString() {
return "spring.ai.chat.client.advisor.type";
return "spring.ai.advisor.type";
}
}
@@ -74,12 +74,12 @@ public enum AdvisorObservationDocumentation implements ObservationDocumentation
public enum HighCardinalityKeyNames implements KeyName {
/**
* Chat Model name.
* Advisor name.
*/
ADVISOR_NAME {
@Override
public String asString() {
return "spring.ai.chat.client.advisor.name";
return "spring.ai.advisor.name";
}
},
/**
@@ -88,7 +88,7 @@ public enum AdvisorObservationDocumentation implements ObservationDocumentation
ADVISOR_ORDER {
@Override
public String asString() {
return "spring.ai.chat.client.advisor.order";
return "spring.ai.advisor.order";
}
}

View File

@@ -17,6 +17,7 @@ package org.springframework.ai.chat.client.advisor.observation;
import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationDocumentation.HighCardinalityKeyNames;
import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationDocumentation.LowCardinalityKeyNames;
import org.springframework.ai.observation.conventions.SpringAiKind;
import org.springframework.ai.util.ParsingUtils;
import org.springframework.lang.Nullable;
@@ -27,18 +28,9 @@ import io.micrometer.common.KeyValues;
* @author Christian Tzolov
* @since 1.0.0
*/
public class DefaultAdvisorObservationConvention implements AdvisorObservationConvention {
public static final String DEFAULT_NAME = "spring.ai.chat.client.advisor";
private static final String CHAT_CLIENT_ADVISOR_SPRING_AI_KIND = "chat_client_advisor";
private static final KeyValue ADVISOR_TYPE_NONE = KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE,
KeyValue.NONE_VALUE);
private static final KeyValue ADVISOR_NAME_NONE = KeyValue.of(HighCardinalityKeyNames.ADVISOR_NAME,
KeyValue.NONE_VALUE);
public static final String DEFAULT_NAME = "spring.ai.advisor";
private final String name;
@@ -73,15 +65,12 @@ public class DefaultAdvisorObservationConvention implements AdvisorObservationCo
}
protected KeyValue advisorType(AdvisorObservationContext context) {
if (context.getAdvisorType() != null) {
return KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE, context.getAdvisorType().name());
}
return ADVISOR_TYPE_NONE;
return KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE, context.getAdvisorType().name());
}
protected KeyValue springAiKind() {
return KeyValue.of(AdvisorObservationDocumentation.LowCardinalityKeyNames.SPRING_AI_KIND,
CHAT_CLIENT_ADVISOR_SPRING_AI_KIND);
SpringAiKind.ADVISOR.value());
}
// ------------------------
@@ -94,14 +83,11 @@ public class DefaultAdvisorObservationConvention implements AdvisorObservationCo
}
protected KeyValue advisorName(AdvisorObservationContext context) {
if (context.getAdvisorType() != null) {
return KeyValue.of(HighCardinalityKeyNames.ADVISOR_NAME, context.getAdvisorName());
}
return ADVISOR_NAME_NONE;
return KeyValue.of(HighCardinalityKeyNames.ADVISOR_NAME, context.getAdvisorName());
}
protected KeyValue advisorOrder(AdvisorObservationContext context) {
return KeyValue.of(HighCardinalityKeyNames.ADVISOR_ORDER, "" + context.getOrder());
}
}
}

View File

@@ -0,0 +1,22 @@
/*
* 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.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.chat.client.advisor.observation;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -15,8 +15,7 @@
*/
package org.springframework.ai.chat.client.observation;
import java.util.stream.Collectors;
import org.springframework.ai.observation.tracing.TracingHelper;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
@@ -32,72 +31,54 @@ import io.micrometer.observation.ObservationFilter;
*/
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));
chatClientSystemText(chatClientObservationContext);
chatClientSystemParams(chatClientObservationContext);
chatClientUserText(chatClientObservationContext);
chatClientUserParams(chatClientObservationContext);
return chatClientObservationContext;
}
protected KeyValue chatClientSystemText(ChatClientObservationContext context) {
if (!StringUtils.hasText(context.getRequest().getUserText())) {
return CHAT_CLIENT_SYSTEM_TEXT_NONE;
protected void chatClientSystemText(ChatClientObservationContext context) {
if (!StringUtils.hasText(context.getRequest().getSystemText())) {
return;
}
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_TEXT,
context.getRequest().getSystemText());
context.addHighCardinalityKeyValue(
KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_TEXT,
context.getRequest().getSystemText()));
}
protected KeyValue chatClientSystemParam(ChatClientObservationContext context) {
protected void chatClientSystemParams(ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().getSystemParams())) {
return CHAT_CLIENT_SYSTEM_PARAM_NONE;
return;
}
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_PARAM,
context.getRequest()
.getSystemParams()
.entrySet()
.stream()
.map(e -> "\"" + e.getKey() + "\":\"" + e.getValue() + "\"")
.collect(Collectors.joining(",", "[", "]")));
context.addHighCardinalityKeyValue(
KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_SYSTEM_PARAM,
TracingHelper.concatenateMaps(context.getRequest().getSystemParams())));
}
protected KeyValue chatClientUserText(ChatClientObservationContext context) {
protected void chatClientUserText(ChatClientObservationContext context) {
if (!StringUtils.hasText(context.getRequest().getUserText())) {
return CHAT_CLIENT_USER_TEXT_NONE;
return;
}
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_TEXT,
context.getRequest().getUserText());
context.addHighCardinalityKeyValue(
KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_TEXT,
context.getRequest().getUserText()));
}
protected KeyValue chatClientUserParam(ChatClientObservationContext context) {
protected void chatClientUserParams(ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().getUserParams())) {
return CHAT_CLIENT_USER_PARAM_NONE;
return;
}
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_PARAMS,
context.getRequest()
.getUserParams()
.entrySet()
.stream()
.map(e -> "\"" + e.getKey() + "\":\"" + e.getValue() + "\"")
.collect(Collectors.joining(",", "[", "]")));
context.addHighCardinalityKeyValue(
KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_USER_PARAMS,
TracingHelper.concatenateMaps(context.getRequest().getUserParams())));
}
}

View File

@@ -21,25 +21,30 @@ import org.springframework.ai.observation.conventions.AiOperationType;
import org.springframework.ai.observation.conventions.AiProvider;
import io.micrometer.observation.Observation;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Context used to store metadata for chat client workflows.
*
* @author Christian Tzolov
* @author Thomas Vitale
* @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) {
private final boolean stream;
@Nullable
private String format;
ChatClientObservationContext(DefaultChatClientRequestSpec requestSpec, String format, boolean isStream) {
Assert.notNull(requestSpec, "requestSpec cannot be null");
this.request = requestSpec;
this.format = format;
this.stream = isStream;
@@ -57,8 +62,49 @@ public class ChatClientObservationContext extends Observation.Context {
return this.stream;
}
@Nullable
public String getFormat() {
return this.format;
}
}
public void setFormat(@Nullable String format) {
this.format = format;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private DefaultChatClientRequestSpec request;
private String format;
private boolean isStream = false;
private Builder() {
}
public Builder withRequest(DefaultChatClientRequestSpec request) {
this.request = request;
return this;
}
public Builder withFormat(String format) {
this.format = format;
return this;
}
public Builder withStream(boolean isStream) {
this.isStream = isStream;
return this;
}
public ChatClientObservationContext build() {
return new ChatClientObservationContext(this.request, this.format, this.isStream);
}
}
}

View File

@@ -19,10 +19,11 @@ import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
/**
* Interface for an {@link ObservationConvention} for chat client workflows.
*
* @author Christian Tzolov
* @since 1.0.0
*/
public interface ChatClientObservationConvention extends ObservationConvention<ChatClientObservationContext> {
@Override

View File

@@ -21,6 +21,8 @@ import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.docs.ObservationDocumentation;
/**
* Documented conventions for chat client observations.
*
* @author Christian Tzolov
* @since 1.0.0
*/
@@ -88,7 +90,7 @@ public enum ChatClientObservationDocumentation implements ObservationDocumentati
CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS {
@Override
public String asString() {
return "spring.ai.chat.client.tool.functioncallbacks";
return "spring.ai.chat.client.tool.function.callbacks";
}
},
@@ -149,4 +151,4 @@ public enum ChatClientObservationDocumentation implements ObservationDocumentati
}
}
}

View File

@@ -15,10 +15,12 @@
*/
package org.springframework.ai.chat.client.observation;
import java.util.stream.Collectors;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation.LowCardinalityKeyNames;
import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.observation.conventions.SpringAiKind;
import org.springframework.ai.observation.tracing.TracingHelper;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
@@ -26,30 +28,16 @@ import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
/**
* Default conventions to populate observations for chat client workflows.
*
* @author Christian Tzolov
* @author Thomas Vitale
* @since 1.0.0
*/
public class DefaultChatClientObservationConvention implements ChatClientObservationConvention {
public static final String DEFAULT_NAME = "spring.ai.chat.client";
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() {
@@ -68,27 +56,12 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva
@Override
@Nullable
public String getContextualName(ChatClientObservationContext context) {
return "%s %s".formatted(context.getOperationMetadata().provider(), CHAT_CLIENT_SPRING_AI_KIND);
return "%s %s".formatted(context.getOperationMetadata().provider(), SpringAiKind.CHAT_CLIENT.value());
}
@Override
public KeyValues getLowCardinalityKeyValues(ChatClientObservationContext context) {
return KeyValues.of(springAiKind(), aiOperationType(context), aiProvider(context), stream(context));
}
@Override
public KeyValues getHighCardinalityKeyValues(ChatClientObservationContext context) {
return KeyValues.of(toolFunctionNames(context), toolFunctionCallbacks(context), chatClientAdvisor(context),
chatClientAdvisorParam(context));
}
protected KeyValue springAiKind() {
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());
return KeyValues.of(aiOperationType(context), aiProvider(context), springAiKind(), stream(context));
}
protected KeyValue aiOperationType(ChatClientObservationContext context) {
@@ -101,55 +74,66 @@ public class DefaultChatClientObservationConvention implements ChatClientObserva
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 springAiKind() {
return KeyValue.of(ChatClientObservationDocumentation.LowCardinalityKeyNames.SPRING_AI_KIND,
SpringAiKind.CHAT_CLIENT.value());
}
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 stream(ChatClientObservationContext context) {
return KeyValue.of(LowCardinalityKeyNames.STREAM, "" + context.isStream());
}
protected KeyValue chatClientAdvisor(ChatClientObservationContext context) {
@Override
public KeyValues getHighCardinalityKeyValues(ChatClientObservationContext context) {
var keyValues = KeyValues.empty();
keyValues = chatClientAdvisorNames(keyValues, context);
keyValues = chatClientAdvisorParams(keyValues, context);
keyValues = toolFunctionNames(keyValues, context);
keyValues = toolFunctionCallbacks(keyValues, context);
return keyValues;
}
protected KeyValues chatClientAdvisorNames(KeyValues keyValues, ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().getAdvisors())) {
return CHAT_CLIENT_ADVISOR_NONE;
return keyValues;
}
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS,
context.getRequest()
.getAdvisors()
.stream()
.map(a -> "\"" + a.getName() + "\"")
.collect(Collectors.joining(",", "[", "]")));
var advisorNames = context.getRequest().getAdvisors().stream().map(Advisor::getName).toList();
return keyValues.and(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(),
TracingHelper.concatenateStrings(advisorNames));
}
protected KeyValue chatClientAdvisorParam(ChatClientObservationContext context) {
protected KeyValues chatClientAdvisorParams(KeyValues keyValues, ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().getAdvisorParams())) {
return CHAT_CLIENT_ADVISOR_PARAM_NONE;
return keyValues;
}
return KeyValue.of(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS,
context.getRequest()
.getAdvisorParams()
.entrySet()
.stream()
.map(e -> "\"" + e.getKey() + "\":\"" + e.getValue() + "\"")
.collect(Collectors.joining(",", "[", "]")));
var advisorParams = context.getRequest().getAdvisorParams();
return keyValues.and(
ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS.asString(),
TracingHelper.concatenateMaps(advisorParams));
}
protected KeyValues toolFunctionNames(KeyValues keyValues, ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().getFunctionNames())) {
return keyValues;
}
var functionNames = context.getRequest().getFunctionNames();
return keyValues.and(
ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES.asString(),
TracingHelper.concatenateStrings(functionNames));
}
protected KeyValues toolFunctionCallbacks(KeyValues keyValues, ChatClientObservationContext context) {
if (CollectionUtils.isEmpty(context.getRequest().getFunctionCallbacks())) {
return keyValues;
}
var functionCallbacks = context.getRequest()
.getFunctionCallbacks()
.stream()
.map(FunctionCallback::getName)
.toList();
return keyValues
.and(ChatClientObservationDocumentation.HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_CALLBACKS
.asString(), TracingHelper.concatenateStrings(functionCallbacks));
}
}

View File

@@ -0,0 +1,22 @@
/*
* 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.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.chat.client.observation;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -25,8 +25,9 @@ public enum SpringAiKind {
// @formatter:off
// Please, keep the alphabetical sorting.
ADVISOR("advisor"),
CHAT_CLIENT("chat_client"),
CHAT_CLIENT_ADVISOR("chat_client_advisor"),
VECTOR_STORE("vector_store");
private final String value;

View File

@@ -28,6 +28,7 @@ import org.springframework.util.StringUtils;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.StringJoiner;
/**
@@ -64,10 +65,16 @@ public final class TracingHelper {
return null;
}
public static String concatenateMaps(Map<String, Object> keyValues) {
var keyValuesJoiner = new StringJoiner(", ", "[", "]");
keyValues.forEach((key, value) -> keyValuesJoiner.add("\"" + key + "\":\"" + value + "\""));
return keyValuesJoiner.toString();
}
public static String concatenateStrings(List<String> strings) {
var promptMessagesJoiner = new StringJoiner(", ", "[", "]");
strings.forEach(string -> promptMessagesJoiner.add("\"" + string + "\""));
return promptMessagesJoiner.toString();
var stringsJoiner = new StringJoiner(", ", "[", "]");
strings.forEach(string -> stringsJoiner.add("\"" + string + "\""));
return stringsJoiner.toString();
}
}

View File

@@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test;
* Unit tests for {@link AdvisorObservationContext}.
*
* @author Christian Tzolov
* @author Thomas Vitale
*/
class AdvisorObservationContextTests {
@@ -42,14 +43,14 @@ class AdvisorObservationContextTests {
assertThatThrownBy(() -> AdvisorObservationContext.builder()
.withAdvisorType(AdvisorObservationContext.Type.BEFORE)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("The advisorName must not be empty!");
.hasMessageContaining("advisorName must not be null or empty");
}
@Test
void missingAdvisorType() {
assertThatThrownBy(() -> AdvisorObservationContext.builder().withAdvisorName("MyName").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("The advisorType must not be null!");
.hasMessageContaining("advisorType must not be null");
}
}

View File

@@ -23,11 +23,13 @@ import org.springframework.ai.chat.client.advisor.observation.AdvisorObservation
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import org.springframework.ai.observation.conventions.SpringAiKind;
/**
* Unit tests for {@link DefaultAdvisorObservationConvention}.
*
* @author Christian Tzolov
* @author Thomas Vitale
*/
class DefaultAdvisorObservationConventionTests {
@@ -64,8 +66,9 @@ class DefaultAdvisorObservationConventionTests {
.withAdvisorType(AdvisorObservationContext.Type.AROUND)
.build();
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains(
KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE.asString(), "AROUND"),
KeyValue.of(LowCardinalityKeyNames.SPRING_AI_KIND.asString(), "chat_client_advisor"));
KeyValue.of(LowCardinalityKeyNames.ADVISOR_TYPE.asString(),
AdvisorObservationContext.Type.AROUND.name()),
KeyValue.of(LowCardinalityKeyNames.SPRING_AI_KIND.asString(), SpringAiKind.ADVISOR.value()));
}
@Test

View File

@@ -33,9 +33,10 @@ import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
/**
* Unit tests for {@link ChatClientImportContentObservationFilter}.
* Unit tests for {@link ChatClientInputContentObservationFilter}.
*
* @author Christian Tzolov
* @author Thomas Vitale
*/
@ExtendWith(MockitoExtension.class)
class ChatClientInputContentObservationFilterTests {
@@ -55,14 +56,13 @@ class ChatClientInputContentObservationFilterTests {
@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 expectedContext = ChatClientObservationContext.builder().withRequest(request).build();
var actualContext = observationFilter.map(expectedContext);
@@ -71,7 +71,6 @@ class ChatClientInputContentObservationFilterTests {
@Test
void whenWithTextThenAugmentContext() {
ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
ChatClientObservationConvention customObservationConvention = null;
@@ -79,7 +78,7 @@ class ChatClientInputContentObservationFilterTests {
"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 originalContext = ChatClientObservationContext.builder().withRequest(request).build();
var augmentedContext = observationFilter.map(originalContext);

View File

@@ -33,6 +33,7 @@ import io.micrometer.observation.ObservationRegistry;
* Unit tests for {@link ChatClientObservationContext}.
*
* @author Christian Tzolov
* @author Thomas Vitale
*/
@ExtendWith(MockitoExtension.class)
class ChatClientObservationContextTests {
@@ -46,7 +47,7 @@ class ChatClientObservationContextTests {
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);
var observationContext = ChatClientObservationContext.builder().withRequest(request).withStream(true).build();
assertThat(observationContext).isNotNull();
}

View File

@@ -38,11 +38,14 @@ import org.springframework.ai.model.function.FunctionCallback;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.observation.conventions.AiProvider;
import org.springframework.ai.observation.conventions.SpringAiKind;
/**
* Unit tests for {@link DefaultChatClientObservationConvention}.
*
* @author Christian Tzolov
* @author Thomas Vitale
*/
@ExtendWith(MockitoExtension.class)
class DefaultChatClientObservationConventionTests {
@@ -67,14 +70,21 @@ class DefaultChatClientObservationConventionTests {
@Test
void shouldHaveContextualName() {
ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "", true);
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
.withRequest(request)
.withStream(true)
.build();
assertThat(this.observationConvention.getContextualName(observationContext)).isEqualTo("spring_ai chat_client");
assertThat(this.observationConvention.getContextualName(observationContext))
.isEqualTo("%s %s".formatted(AiProvider.SPRING_AI.value(), SpringAiKind.CHAT_CLIENT.value()));
}
@Test
void supportsOnlyChatClientObservationContext() {
ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "", true);
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
.withRequest(request)
.withStream(true)
.build();
assertThat(this.observationConvention.supportsContext(observationContext)).isTrue();
assertThat(this.observationConvention.supportsContext(new Observation.Context())).isFalse();
@@ -82,7 +92,10 @@ class DefaultChatClientObservationConventionTests {
@Test
void shouldHaveRequiredKeyValues() {
ChatClientObservationContext observationContext = new ChatClientObservationContext(request, "", true);
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
.withRequest(request)
.withStream(true)
.build();
assertThat(this.observationConvention.getLowCardinalityKeyValues(observationContext)).contains(
KeyValue.of(LowCardinalityKeyNames.SPRING_AI_KIND.asString(), "chat_client"),
@@ -143,38 +156,27 @@ class DefaultChatClientObservationConventionTests {
@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);
ChatClientObservationContext observationContext = ChatClientObservationContext.builder()
.withRequest(request)
.withFormat("json")
.withStream(true)
.build();
assertThat(this.observationConvention.getHighCardinalityKeyValues(observationContext)).contains(
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISORS.asString(),
"[\"advisor1\",\"advisor2\",\"CallAroundAdvisor\",\"StreamAroundAdvisor\"]"),
"[\"advisor1\", \"advisor2\", \"CallAroundAdvisor\", \"StreamAroundAdvisor\"]"),
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_ADVISOR_PARAMS.asString(),
"[\"advParam1\":\"advisorParam1Value\"]"),
KeyValue.of(HighCardinalityKeyNames.CHAT_CLIENT_TOOL_FUNCTION_NAMES.asString(),
"[\"function1\",\"function2\"]"),
"[\"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;
}
"[\"functionCallback1\", \"functionCallback2\"]"));
}
}

View File

@@ -118,7 +118,6 @@ public class ChromaVectorStoreAutoConfigurationIT {
.hasObservationWithNameEqualTo(DefaultVectorStoreObservationConvention.DEFAULT_NAME)
.that()
.hasContextualNameEqualTo("chroma delete")
.hasHighCardinalityKeyValue(HighCardinalityKeyNames.DB_VECTOR_QUERY_FILTER.asString(), "none")
.hasBeenStarted()
.hasBeenStopped();
observationRegistry.clear();