diff --git a/core/deployment/pom.xml b/core/deployment/pom.xml index a42bdd846..995235a18 100644 --- a/core/deployment/pom.xml +++ b/core/deployment/pom.xml @@ -112,6 +112,21 @@ quarkus-test-vertx test + + io.opentelemetry.instrumentation + opentelemetry-instrumentation-api + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + io.quarkus + quarkus-junit5-mockito + test + diff --git a/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorAbstractSpanChatModelListenerTest.java b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorAbstractSpanChatModelListenerTest.java new file mode 100644 index 000000000..7829150e2 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorAbstractSpanChatModelListenerTest.java @@ -0,0 +1,125 @@ +package io.quarkiverse.langchain4j.test.listeners; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.HashMap; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.DefaultChatRequestParameters; +import dev.langchain4j.model.chat.response.ChatResponse; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.internal.data.ExceptionEventData; +import io.quarkiverse.langchain4j.runtime.listeners.ChatModelSpanContributor; +import io.quarkiverse.langchain4j.runtime.listeners.SpanChatModelListener; +import io.quarkus.arc.All; + +abstract class ListenersProcessorAbstractSpanChatModelListenerTest { + @Inject + SpanChatModelListener spanChatModelListener; + @Inject + @All + List contributors; + @Inject + InMemorySpanExporter exporter; + + static JavaArchive appWithInMemorySpanExporter() { + var applicationProperties = """ + # Since using a InMemorySpanExporter inside our tests, + # these properties reduce the export timeout and schedule delay from the BatchSpanProcessor + quarkus.otel.bsp.schedule.delay=PT0.001S + quarkus.otel.bsp.max.queue.size=1 + quarkus.otel.bsp.max.export.batch.size=1 + """; + return ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset(applicationProperties), "application.properties") + .addClasses(InMemorySpanExporterProducer.class); + } + + @BeforeEach + void cleanupSpans() { + exporter.reset(); + } + + @Test + void shouldCreateSpanOnSuccess() { + var ctx = MockedContexts.create(); + + spanChatModelListener.onRequest(ctx.requestContext()); + spanChatModelListener.onResponse(ctx.responseContext()); + + await().untilAsserted(() -> assertThat(exporter.getFinishedSpanItems()).hasSize(1)); + var actualSpan = exporter.getFinishedSpanItems().get(0); + assertThat(actualSpan.getName()).isEqualTo("completion --mock-model-name--"); + verifySuccessfulSpan(actualSpan); + } + + protected void verifySuccessfulSpan(SpanData actualSpan) { + // nothing here + } + + @Test + void shouldCreateAndEndSpanOnFailure() { + var ctx = MockedContexts.create(); + + spanChatModelListener.onRequest(ctx.requestContext()); + spanChatModelListener.onError(ctx.errorContext()); + + await().untilAsserted(() -> assertThat(exporter.getFinishedSpanItems()).hasSize(1)); + var actualSpan = exporter.getFinishedSpanItems().get(0); + assertThat(actualSpan.getName()).isEqualTo("completion --mock-model-name--"); + assertThat(actualSpan.getEvents()) + .hasSize(1) + .first() + .isInstanceOf(ExceptionEventData.class) + .extracting(ex -> ((ExceptionEventData) ex).getException().getMessage()) + .isEqualTo("--failed--"); + verifyFailedSpan(actualSpan); + } + + protected void verifyFailedSpan(SpanData actualSpan) { + // nothing here + } + + public static class InMemorySpanExporterProducer { + @ApplicationScoped + InMemorySpanExporter exporter() { + return InMemorySpanExporter.create(); + } + } + + record MockedContexts( + ChatModelRequestContext requestContext, + ChatModelResponseContext responseContext, + ChatModelErrorContext errorContext) { + static MockedContexts create() { + var attributes = new HashMap(); + var request = ChatRequest.builder().messages(List.of(UserMessage.from("--test-message--"))) + .parameters(DefaultChatRequestParameters.builder().modelName("--mock-model-name--").temperature(0.0) + .topP(0.0).build()) + .build(); + var response = ChatResponse.builder().aiMessage(AiMessage.from("--test-response--")).build(); + var requestCtx = new ChatModelRequestContext(request, attributes); + var responseContext = new ChatModelResponseContext(response, request, attributes); + var errorCtx = new ChatModelErrorContext( + new RuntimeException("--failed--"), request, attributes); + return new MockedContexts(requestCtx, responseContext, errorCtx); + } + } +} diff --git a/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorNoOpentelemetryTest.java b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorNoOpentelemetryTest.java new file mode 100644 index 000000000..af2d014ca --- /dev/null +++ b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorNoOpentelemetryTest.java @@ -0,0 +1,37 @@ +package io.quarkiverse.langchain4j.test.listeners; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.langchain4j.runtime.listeners.ChatModelSpanContributor; +import io.quarkiverse.langchain4j.runtime.listeners.SpanChatModelListener; +import io.quarkus.arc.All; +import io.quarkus.test.QuarkusUnitTest; + +class ListenersProcessorNoOpentelemetryTest { + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class)); + + @Inject + @All + List spanChatModelListeners; + @Inject + @All + List chatModelSpanContributors; + + @Test + void shouldNotHaveSpanChatModelListenerWhenNoOtel() { + assertThat(spanChatModelListeners).isEmpty(); + assertThat(chatModelSpanContributors).isEmpty(); + } +} diff --git a/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorOnlySpanChatModelListenerTest.java b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorOnlySpanChatModelListenerTest.java new file mode 100644 index 000000000..bc2929759 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorOnlySpanChatModelListenerTest.java @@ -0,0 +1,27 @@ +package io.quarkiverse.langchain4j.test.listeners; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.maven.dependency.Dependency; +import io.quarkus.test.QuarkusUnitTest; + +class ListenersProcessorOnlySpanChatModelListenerTest + extends ListenersProcessorAbstractSpanChatModelListenerTest { + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer( + ListenersProcessorAbstractSpanChatModelListenerTest::appWithInMemorySpanExporter) + .setForcedDependencies( + List.of(Dependency.of("io.quarkus", "quarkus-opentelemetry", "3.15.2"))); + + @Test + void shouldHaveSpanChatModelListenerWithoutContributors() { + assertThat(spanChatModelListener).isNotNull(); + assertThat(contributors).isEmpty(); + } +} diff --git a/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorSingleChatModelSpanContributorTest.java b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorSingleChatModelSpanContributorTest.java new file mode 100644 index 000000000..875a00f69 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorSingleChatModelSpanContributorTest.java @@ -0,0 +1,74 @@ +package io.quarkiverse.langchain4j.test.listeners; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.quarkiverse.langchain4j.runtime.listeners.ChatModelSpanContributor; +import io.quarkus.maven.dependency.Dependency; +import io.quarkus.test.QuarkusUnitTest; + +class ListenersProcessorSingleChatModelSpanContributorTest + extends ListenersProcessorAbstractSpanChatModelListenerTest { + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer( + () -> appWithInMemorySpanExporter().addClasses(TestChatModelSpanContributor.class)) + .setForcedDependencies( + List.of(Dependency.of("io.quarkus", "quarkus-opentelemetry", "3.15.2"))); + + @Test + void shouldHaveSpanChatModelListenerWitContributor() { + assertThat(spanChatModelListener).isNotNull(); + assertThat(contributors).hasSize(1).first().isInstanceOf(TestChatModelSpanContributor.class); + } + + @Override + protected void verifySuccessfulSpan(SpanData actualSpan) { + assertThat(actualSpan.getAttributes().get(AttributeKey.stringKey(("--custom-on-request--")))) + .isEqualTo("--value-on-request--"); + assertThat(actualSpan.getAttributes().get(AttributeKey.stringKey(("--custom-on-response--")))) + .isEqualTo("--value-on-response--"); + assertThat(actualSpan.getAttributes().get(AttributeKey.stringKey(("--custom-on-error--")))) + .isNull(); + } + + @Override + protected void verifyFailedSpan(SpanData actualSpan) { + assertThat(actualSpan.getAttributes().get(AttributeKey.stringKey(("--custom-on-request--")))) + .isEqualTo("--value-on-request--"); + assertThat(actualSpan.getAttributes().get(AttributeKey.stringKey(("--custom-on-response--")))) + .isNull(); + assertThat(actualSpan.getAttributes().get(AttributeKey.stringKey(("--custom-on-error--")))) + .isEqualTo("--value-on-error--"); + } + + @ApplicationScoped + public static class TestChatModelSpanContributor implements ChatModelSpanContributor { + @Override + public void onRequest(ChatModelRequestContext requestContext, Span currentSpan) { + currentSpan.setAttribute("--custom-on-request--", "--value-on-request--"); + } + + @Override + public void onResponse(ChatModelResponseContext responseContext, Span currentSpan) { + currentSpan.setAttribute("--custom-on-response--", "--value-on-response--"); + } + + @Override + public void onError(ChatModelErrorContext errorContext, Span currentSpan) { + currentSpan.setAttribute("--custom-on-error--", "--value-on-error--"); + } + } +} diff --git a/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorTwoChatModelSpanContributorsTest.java b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorTwoChatModelSpanContributorsTest.java new file mode 100644 index 000000000..e8f15d2f8 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkiverse/langchain4j/test/listeners/ListenersProcessorTwoChatModelSpanContributorsTest.java @@ -0,0 +1,157 @@ +package io.quarkiverse.langchain4j.test.listeners; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.util.List; +import java.util.function.BiConsumer; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.internal.data.ExceptionEventData; +import io.quarkiverse.langchain4j.runtime.listeners.ChatModelSpanContributor; +import io.quarkus.maven.dependency.Dependency; +import io.quarkus.test.QuarkusUnitTest; + +class ListenersProcessorTwoChatModelSpanContributorsTest + extends ListenersProcessorAbstractSpanChatModelListenerTest { + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer( + () -> appWithInMemorySpanExporter() + .addClasses( + FirstChatModelSpanContributor.class, + SecondChatModelSpanContributor.class)) + .setForcedDependencies( + List.of(Dependency.of("io.quarkus", "quarkus-opentelemetry", "3.15.2"))); + + static BiConsumer onRequest; + static BiConsumer onResponse; + static BiConsumer onError; + + @BeforeEach + void setupConsumers() { + onRequest = mock(BiConsumer.class); + onResponse = mock(BiConsumer.class); + onError = mock(BiConsumer.class); + } + + @Test + void shouldHaveSpanChatModelListenerWitContributor() { + assertThat(spanChatModelListener).isNotNull(); + assertThat(contributors) + .hasSize(2); + } + + @Test + void shouldBeResilientToContributorFailuresOnRequest() { + var ctx = MockedContexts.create(); + var failure = new RuntimeException("--failure-on-request-of-contributor--"); + doThrow(failure).when(onRequest).accept(any(), any()); + + spanChatModelListener.onRequest(ctx.requestContext()); + spanChatModelListener.onResponse(ctx.responseContext()); + + await().untilAsserted(() -> assertThat(exporter.getFinishedSpanItems()).hasSize(1)); + verify(onRequest, times(2)).accept(any(), any()); + var actualSpan = exporter.getFinishedSpanItems().get(0); + assertThat(actualSpan.getEvents()) + .hasSize(2) + .extracting(ex -> ((ExceptionEventData) ex).getException().getMessage()) + .contains("--failure-on-request-of-contributor--", "--failure-on-request-of-contributor--"); + } + + @Test + void shouldBeResilientToContributorFailuresOnResponse() { + var ctx = MockedContexts.create(); + var failure = new RuntimeException("--failure-on-response-of-contributor--"); + doThrow(failure).when(onResponse).accept(any(), any()); + + spanChatModelListener.onRequest(ctx.requestContext()); + spanChatModelListener.onResponse(ctx.responseContext()); + + await().untilAsserted(() -> assertThat(exporter.getFinishedSpanItems()).hasSize(1)); + verify(onResponse, times(2)).accept(any(), any()); + var actualSpan = exporter.getFinishedSpanItems().get(0); + assertThat(actualSpan.getEvents()) + .hasSize(2) + .extracting(ex -> ((ExceptionEventData) ex).getException().getMessage()) + .contains( + "--failure-on-response-of-contributor--", "--failure-on-response-of-contributor--"); + } + + @Test + void shouldBeResilientToContributorFailuresOnError() { + var ctx = MockedContexts.create(); + var failure = new RuntimeException("--failure-on-error-of-contributor--"); + doThrow(failure).when(onError).accept(any(), any()); + + spanChatModelListener.onRequest(ctx.requestContext()); + spanChatModelListener.onError(ctx.errorContext()); + + await().untilAsserted(() -> assertThat(exporter.getFinishedSpanItems()).hasSize(1)); + verify(onError, times(2)).accept(any(), any()); + var actualSpan = exporter.getFinishedSpanItems().get(0); + assertThat(actualSpan.getEvents()) + .hasSize(3) + .extracting(ex -> ((ExceptionEventData) ex).getException().getMessage()) + .contains( + "--failure-on-error-of-contributor--", + "--failure-on-error-of-contributor--", + ctx.errorContext().error().getMessage()); + } + + @Override + protected void verifySuccessfulSpan(SpanData actualSpan) { + verify(onRequest, times(2)).accept(any(), any()); + verify(onResponse, times(2)).accept(any(), any()); + verifyNoInteractions(onError); + } + + @Override + protected void verifyFailedSpan(SpanData actualSpan) { + verify(onRequest, times(2)).accept(any(), any()); + verifyNoInteractions(onResponse); + verify(onError, times(2)).accept(any(), any()); + } + + @ApplicationScoped + public static class FirstChatModelSpanContributor extends AbstractChatModelSpanContributor { + } + + @ApplicationScoped + public static class SecondChatModelSpanContributor extends AbstractChatModelSpanContributor { + } + + public static class AbstractChatModelSpanContributor implements ChatModelSpanContributor { + @Override + public void onRequest(ChatModelRequestContext requestContext, Span currentSpan) { + onRequest.accept(requestContext, currentSpan); + } + + @Override + public void onResponse(ChatModelResponseContext responseContext, Span currentSpan) { + onResponse.accept(responseContext, currentSpan); + } + + @Override + public void onError(ChatModelErrorContext errorContext, Span currentSpan) { + onError.accept(errorContext, currentSpan); + } + } +} diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/listeners/ChatModelSpanContributor.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/listeners/ChatModelSpanContributor.java new file mode 100644 index 000000000..8d6384472 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/listeners/ChatModelSpanContributor.java @@ -0,0 +1,50 @@ +package io.quarkiverse.langchain4j.runtime.listeners; + +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelRequest; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponse; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import io.opentelemetry.api.trace.Span; + +/** + * Contributes custom attributes, events or other data to the spans created by {@link SpanChatModelListener}. + */ +public interface ChatModelSpanContributor { + /** + * Allows for custom data to be added to the span. + * + * @param requestContext The request context. It contains the {@link ChatModelRequest} and attributes. + * The attributes can be used to pass data between methods of this listener + * or between multiple listeners. + * @param currentSpan Span opened by {@link SpanChatModelListener}. + */ + default void onRequest(ChatModelRequestContext requestContext, Span currentSpan) { + } + + /** + * Allows for custom data to be added to the span. + * + * @param responseContext The response context. + * It contains {@link ChatModelResponse}, corresponding {@link ChatModelRequest} and attributes. + * The attributes can be used to pass data between methods of this listener + * or between multiple listeners. + * @param currentSpan Span opened by {@link SpanChatModelListener}. + */ + default void onResponse(ChatModelResponseContext responseContext, Span currentSpan) { + } + + /** + * Allows for custom data to be added to the span. + * + * @param errorContext The error context. + * It contains the error, corresponding {@link ChatModelRequest}, + * partial {@link ChatModelResponse} (if available) and attributes. + * The attributes can be used to pass data between methods of this listener + * or between multiple listeners. + * @param currentSpan Span opened by {@link SpanChatModelListener}. + */ + default void onError(ChatModelErrorContext errorContext, Span currentSpan) { + } + +} diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/listeners/SpanChatModelListener.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/listeners/SpanChatModelListener.java index 5dade6b6d..f0730eda9 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/listeners/SpanChatModelListener.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/listeners/SpanChatModelListener.java @@ -1,5 +1,6 @@ package io.quarkiverse.langchain4j.runtime.listeners; +import java.util.List; import java.util.Map; import jakarta.inject.Inject; @@ -18,6 +19,7 @@ import io.opentelemetry.context.Scope; import io.quarkiverse.langchain4j.cost.Cost; import io.quarkiverse.langchain4j.cost.CostEstimatorService; +import io.quarkus.arc.All; /** * Creates a span that follows the @@ -33,11 +35,14 @@ public class SpanChatModelListener implements ChatModelListener { private final Tracer tracer; private final CostEstimatorService costEstimatorService; + private final List chatModelSpanContributors; @Inject - public SpanChatModelListener(Tracer tracer, CostEstimatorService costEstimatorService) { + public SpanChatModelListener(Tracer tracer, CostEstimatorService costEstimatorService, + @All List chatModelSpanContributors) { this.tracer = tracer; this.costEstimatorService = costEstimatorService; + this.chatModelSpanContributors = chatModelSpanContributors; } @Override @@ -53,6 +58,7 @@ public void onRequest(ChatModelRequestContext requestContext) { var attributes = requestContext.attributes(); attributes.put(OTEL_SCOPE_KEY_NAME, scope); attributes.put(OTEL_SPAN_KEY_NAME, span); + notifyContributorsOnRequest(requestContext, span); } @Override @@ -78,6 +84,7 @@ public void onResponse(ChatModelResponseContext responseContext) { span.setAttribute("gen_ai.client.estimated_cost", costEstimate.toString()); } } + notifyContributorsOnResponse(responseContext, span); span.end(); } else { // should never happen @@ -92,6 +99,8 @@ public void onError(ChatModelErrorContext errorContext) { Span span = (Span) attributes.get(OTEL_SPAN_KEY_NAME); if (span != null) { span.recordException(errorContext.error()); + notifyContributorsOnError(errorContext, span); + span.end(); } else { // should never happen log.warn("No Span found in response"); @@ -112,4 +121,39 @@ private void safeCloseScope(Map attributes) { } } } + + private void notifyContributorsOnRequest(ChatModelRequestContext requestContext, Span span) { + for (ChatModelSpanContributor contributor : chatModelSpanContributors) { + try { + contributor.onRequest(requestContext, span); + } catch (Exception ex) { + recordLogAndSwallow(span, ex); + } + } + } + + private void notifyContributorsOnResponse(ChatModelResponseContext responseContext, Span span) { + for (ChatModelSpanContributor contributor : chatModelSpanContributors) { + try { + contributor.onResponse(responseContext, span); + } catch (Exception ex) { + recordLogAndSwallow(span, ex); + } + } + } + + private void notifyContributorsOnError(ChatModelErrorContext errorContext, Span span) { + for (ChatModelSpanContributor contributor : chatModelSpanContributors) { + try { + contributor.onError(errorContext, span); + } catch (Exception ex) { + recordLogAndSwallow(span, ex); + } + } + } + + private void recordLogAndSwallow(Span span, Exception ex) { + span.recordException(ex); + log.warn("failure on contributor", ex); + } } diff --git a/docs/modules/ROOT/pages/ai-services.adoc b/docs/modules/ROOT/pages/ai-services.adoc index bc9633174..16c6c966a 100644 --- a/docs/modules/ROOT/pages/ai-services.adoc +++ b/docs/modules/ROOT/pages/ai-services.adoc @@ -32,9 +32,8 @@ Once registered, you can inject the _AI Service_ into your application: The beans created by `@RegisterAiService` are `@RequestScoped` by default. The reason for this is that it enables removing chat <> objects. This is a good default when a service is used during when handling an HTTP request, but it's inappropriate in CLIs or in WebSockets (WebSocket support is expected to improve in the near future). For example when using a service in a CLI, it makes sense to have the service be `@ApplicationScoped` and the extension allows this simply if the service is annotated with `@ApplicationScoped`. -==== -== AI method declaration +=== AI method declaration Within the interface annotated with `@RegisterAiService`, you model interactions with the LLM using _methods_. These methods accept parameters and are annotated with `@SystemMessage` and `@UserMessage` to define instructions directed to the LLM: @@ -341,7 +340,7 @@ Observability is built into services created via `@RegisterAiService` and is pro * Metrics are enabled when `quarkus-micrometer` is part of the application * Traces are enabled when `quarkus-opentelemetry` is part of the application - + === Metrics Each AI method is automatically timed and the timer data is available using the `langchain4j.aiservices.$interface_name.$method_name` template for the name. @@ -444,6 +443,32 @@ In the trace above we can see the parent span which corresponds to the handling interesting thing is the `langchain4j.aiservices.MyAiService.writeAPoem` span which corresponds to the invocation of the AI service. The child spans of this span correspond (from to right) to calling the OpenAI API, invoking the `sendEmail` tool and finally invoking calling the OpenAI API again. +==== Custom span data +if you have the need for custom span data, you can simply add a bean implemtenting `ChatModelSpanContributor`. +[source,java] +---- +import io.quarkiverse.langchain4j.runtime.listeners.ChatModelSpanContributor; +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import io.opentelemetry.api.trace.Span; + +@ApplicationScoped +public class CustomSpanDataContributor implements ChatModelSpanContributor { + public void onRequest(ChatModelRequestContext requestContext, Span currentSpan) { + span.addAttribute("example", "request"); + } + + public void onResponse(ChatModelResponseContext responseContext, Span currentSpan) { + span.addAttribute("example", "response"); + } + + default void onError(ChatModelErrorContext errorContext, Span currentSpan) { + span.addAttribute("example", "failure"); + } +} +---- + === Auditing The extension allows users to audit the process of implementing an AiService by introducing `io.quarkiverse.langchain4j.audit.AuditService` and `io.quarkiverse.langchain4j.audit.Audit`. diff --git a/pom.xml b/pom.xml index 3eb0a0c62..b8e25d208 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,11 @@ quarkus-extension-processor ${quarkus.extension-processor.version} + + io.opentelemetry + opentelemetry-sdk-testing + 1.46.0 +