diff --git a/CHANGELOG.next-release.md b/CHANGELOG.next-release.md index 70715229..92bd0c86 100644 --- a/CHANGELOG.next-release.md +++ b/CHANGELOG.next-release.md @@ -1,3 +1,4 @@ * add dynamically disabled instrumentation capability - #422 * add disable all instrumentations option - #471 * add stop-sending option - #474 +* Add OpenAI client instrumentation - #497 diff --git a/agentextension/build.gradle.kts b/agentextension/build.gradle.kts index 2bf38814..3745cb9f 100644 --- a/agentextension/build.gradle.kts +++ b/agentextension/build.gradle.kts @@ -2,7 +2,7 @@ plugins { id("elastic-otel.java-conventions") id("elastic-otel.sign-and-publish-conventions") id("elastic-otel.license-report-conventions") - id("com.github.johnrengelman.shadow") + id("com.gradleup.shadow") } description = "Bundles all elastic extensions in a fat-jar to be used" + diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 1061562a..8be49dd4 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,8 +15,15 @@ repositories { dependencies { implementation(catalog.spotlessPlugin) - implementation(catalog.shadowPlugin) implementation(catalog.licenseReportPlugin) + // muzzle pulls in ancient versions of http components which conflict with other plugins, such as jib + implementation(catalog.muzzleGenerationPlugin) { + exclude(group = "org.apache.httpcomponents" ) + } + implementation(catalog.muzzleCheckPlugin) { + exclude(group = "org.apache.httpcomponents" ) + } + implementation(catalog.shadowPlugin) // The ant dependency is required to add custom transformers for the shadow plugin // but it is unfortunately not exposed as transitive dependency implementation(catalog.ant) diff --git a/buildSrc/src/main/kotlin/elastic-otel.agent-packaging-conventions.gradle.kts b/buildSrc/src/main/kotlin/elastic-otel.agent-packaging-conventions.gradle.kts index 8687187a..0c3c0ecf 100644 --- a/buildSrc/src/main/kotlin/elastic-otel.agent-packaging-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/elastic-otel.agent-packaging-conventions.gradle.kts @@ -8,7 +8,7 @@ import org.objectweb.asm.Opcodes plugins { id("elastic-otel.java-conventions") - id("com.github.johnrengelman.shadow") + id("com.gradleup.shadow") } diff --git a/buildSrc/src/main/kotlin/elastic-otel.instrumentation-conventions.gradle.kts b/buildSrc/src/main/kotlin/elastic-otel.instrumentation-conventions.gradle.kts new file mode 100644 index 00000000..19418245 --- /dev/null +++ b/buildSrc/src/main/kotlin/elastic-otel.instrumentation-conventions.gradle.kts @@ -0,0 +1,93 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + `java-library` + id("elastic-otel.java-conventions") + id("io.opentelemetry.instrumentation.muzzle-generation") + id("io.opentelemetry.instrumentation.muzzle-check") +} + +// Other instrumentations to include for testing +val testInstrumentation: Configuration by configurations.creating { + isCanBeConsumed = false +} +val agentForTesting: Configuration by configurations.creating { + isCanBeConsumed = false +} + +//https://github.com/gradle/gradle/issues/15383 +val catalog = extensions.getByType().named("catalog") +dependencies { + agentForTesting(platform(catalog.findLibrary("opentelemetryInstrumentationAlphaBom").get())) + agentForTesting("io.opentelemetry.javaagent:opentelemetry-agent-for-testing") + + compileOnly("io.opentelemetry:opentelemetry-sdk") + compileOnly("io.opentelemetry.instrumentation:opentelemetry-instrumentation-api") + compileOnly("io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api") + + testImplementation("io.opentelemetry.javaagent:opentelemetry-testing-common") + testImplementation("io.opentelemetry:opentelemetry-sdk-testing") + + val agentVersion = catalog.findVersion("opentelemetryJavaagentAlpha").get() + add("codegen", "io.opentelemetry.javaagent:opentelemetry-javaagent-tooling:${agentVersion}") + add("muzzleBootstrap", "io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations-support:${agentVersion}") + add("muzzleTooling", "io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api:${agentVersion}") + add("muzzleTooling", "io.opentelemetry.javaagent:opentelemetry-javaagent-tooling:${agentVersion}") +} + +fun relocatePackages( shadowJar : ShadowJar) { + // rewrite dependencies calling Logger.getLogger + shadowJar.relocate("java.util.logging.Logger", "io.opentelemetry.javaagent.bootstrap.PatchLogger") + + // prevents conflict with library instrumentation, since these classes live in the bootstrap class loader + shadowJar.relocate("io.opentelemetry.instrumentation", "io.opentelemetry.javaagent.shaded.instrumentation") { + // Exclude resource providers since they live in the agent class loader + exclude("io.opentelemetry.instrumentation.resources.*") + exclude("io.opentelemetry.instrumentation.spring.resources.*") + } + + // relocate(OpenTelemetry API) since these classes live in the bootstrap class loader + shadowJar.relocate("io.opentelemetry.api", "io.opentelemetry.javaagent.shaded.io.opentelemetry.api") + shadowJar.relocate("io.opentelemetry.semconv", "io.opentelemetry.javaagent.shaded.io.opentelemetry.semconv") + shadowJar.relocate("io.opentelemetry.context", "io.opentelemetry.javaagent.shaded.io.opentelemetry.context") + shadowJar.relocate("io.opentelemetry.extension.incubator", "io.opentelemetry.javaagent.shaded.io.opentelemetry.extension.incubator") + + // relocate the OpenTelemetry extensions that are used by instrumentation modules + // these extensions live in the AgentClassLoader, and are injected into the user's class loader + // by the instrumentation modules that use them + shadowJar.relocate("io.opentelemetry.extension.aws", "io.opentelemetry.javaagent.shaded.io.opentelemetry.extension.aws") + shadowJar.relocate("io.opentelemetry.extension.kotlin", "io.opentelemetry.javaagent.shaded.io.opentelemetry.extension.kotlin") +} + +tasks { + shadowJar { + configurations = listOf(project.configurations.runtimeClasspath.get(), testInstrumentation) + mergeServiceFiles() + + archiveFileName.set("agent-testing.jar") + relocatePackages(this) + } +} + +tasks.withType().configureEach { + dependsOn(tasks.shadowJar, agentForTesting) + + jvmArgs( + "-Dotel.javaagent.debug=true", + "-javaagent:${agentForTesting.files.first().absolutePath}", + // loads the given just jar, but in contrast to external extensions doesn't perform runtime shading + // instead the instrumentations are expected to be correctly shaded already in the jar + // Also the classes end up in the agent classloader instead of the extension loader + "-Dotel.javaagent.experimental.initializer.jar=${tasks.shadowJar.get().archiveFile.get().asFile.absolutePath}", + "-Dotel.javaagent.testing.additional-library-ignores.enabled=false", + "-Dotel.javaagent.testing.fail-on-context-leak=true", + "-Dotel.javaagent.testing.transform-safe-logging.enabled=true", + "-Dotel.metrics.exporter=otlp" + ) + + // The sources are packaged into the testing jar so we need to make sure to exclude from the test + // classpath, which automatically inherits them, to ensure our shaded versions are used. + classpath = classpath.filter { + return@filter !(it == file("${layout.buildDirectory.get()}/resources/main") || it == file("${layout.buildDirectory.get()}/classes/java/main")) + } +} diff --git a/custom/build.gradle.kts b/custom/build.gradle.kts index 4802fac2..e0eb65d6 100644 --- a/custom/build.gradle.kts +++ b/custom/build.gradle.kts @@ -2,11 +2,19 @@ plugins { id("elastic-otel.library-packaging-conventions") } +val instrumentations = listOf( + ":instrumentation:openai-client-instrumentation" +) + dependencies { implementation(project(":common")) implementation(project(":inferred-spans")) implementation(project(":universal-profiling-integration")) implementation(project(":resources")) + instrumentations.forEach { + implementation(project(it)) + } + compileOnly("io.opentelemetry:opentelemetry-sdk") compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") compileOnly("io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api") @@ -37,6 +45,21 @@ dependencies { testImplementation("io.opentelemetry:opentelemetry-sdk-testing") testImplementation(libs.freemarker) } + +tasks { + instrumentations.forEach { + // TODO: instrumentation dependencies must be declared here explicitly atm, otherwise gradle complains + // about it being missing we need to figure out a way of including it in the "compileJava" task + // to not have to do this + javadoc { + dependsOn("$it:byteBuddyJava") + } + compileTestJava { + dependsOn("$it:byteBuddyJava") + } + } +} + tasks.withType { val overrideConfig = project.properties["elastic.otel.overwrite.config.docs"] if (overrideConfig != null) { diff --git a/docs/configure.md b/docs/configure.md index ad0285c9..707fe4bf 100644 --- a/docs/configure.md +++ b/docs/configure.md @@ -111,6 +111,26 @@ _Currently there are no additional `OTEL_` options waiting to be contributed ups | `ELASTIC_OTEL_UNIVERSAL_PROFILING_INTEGRATION_*` | [Universal profiling integration](https://github.com/elastic/elastic-otel-java/tree/main/universal-profiling-integration) | Correlates traces with profiling data from the Elastic universal profiler. | | `ELASTIC_OTEL_JAVA_SPAN_STACKTRACE_MIN_DURATION` | [Span stacktrace capture](https://github.com/open-telemetry/opentelemetry-java-contrib/tree/main/span-stacktrace) | Define the minimum duration for attaching stack traces to spans. Defaults to 5ms. | +### Instrumentations that are _only_ available in EDOT Java + +Some instrumentation are only available in EDOT Java and might or might not be added upstream in future versions. + +#### OpenAI Java Client + +Instrumentation for the [official OpenAI Java Client](https://github.com/openai/openai-java). +It supports: + * Tracing for requests, including GenAI-specific attributes such as token usage + * Opt-In logging of OpenAI request and response content payloads + +This instrumentation is currently **experimental**. It can by disabled by setting either the `OTEL_INSTRUMENTATION_OPENAI_CLIENT_ENABLED` environment variable or the `otel.instrumentation.openai-client.enabled` JVM property to `false`. + +In addition, this instrumentation provides the following configuration options: + +| Option(s) | Description | +|-------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ELASTIC_OTEL_JAVA_INSTRUMENTATION_GENAI_EMIT_EVENTS` | If set to `true`, the agent will generate log events for OpenAI requests and responses. Potentially sensitive content will only be included if `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` is true. Defaults to the value of `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`, so that just enabling `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` ensures that log events are generated. | +| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | If set to `true`, enables the capturing of OpenAI request and response content in the log events outputted by the agent. Defaults to `false` | + ## Authentication methods diff --git a/gradle/instrumentation.gradle b/gradle/instrumentation.gradle deleted file mode 100644 index c240695e..00000000 --- a/gradle/instrumentation.gradle +++ /dev/null @@ -1,68 +0,0 @@ -apply plugin: 'java' -apply plugin: 'com.github.johnrengelman.shadow' -apply plugin: 'io.opentelemetry.instrumentation.muzzle-generation' -apply plugin: 'io.opentelemetry.instrumentation.muzzle-check' - -apply from: "$rootDir/gradle/shadow.gradle" - -def relocatePackages = ext.relocatePackages - -configurations { - testInstrumentation - testAgent -} - -dependencies { - compileOnly("io.opentelemetry:opentelemetry-sdk") - compileOnly("io.opentelemetry.instrumentation:opentelemetry-instrumentation-api") - compileOnly("io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api") - - annotationProcessor deps.autoservice - compileOnly deps.autoservice - - // the javaagent that is going to be used when running instrumentation unit tests - testAgent(project(path: ":testing:agent-for-testing", configuration: "shadow")) - // test dependencies - testImplementation("io.opentelemetry.javaagent:opentelemetry-testing-common") - testImplementation("io.opentelemetry:opentelemetry-sdk-testing") - testImplementation(catalog.assertj.core) - - add("codegen", "io.opentelemetry.javaagent:opentelemetry-javaagent-tooling") - add("muzzleBootstrap", "io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations-support") - add("muzzleTooling", "io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api") - add("muzzleTooling", "io.opentelemetry.javaagent:opentelemetry-javaagent-tooling") -} - -shadowJar { - configurations = [project.configurations.runtimeClasspath, project.configurations.testInstrumentation] - mergeServiceFiles() - - archiveFileName = 'agent-testing.jar' - - relocatePackages(it) -} - -tasks.withType(Test).configureEach { - inputs.file(shadowJar.archiveFile) - - jvmArgs "-Dotel.javaagent.debug=true" - jvmArgs "-javaagent:${configurations.testAgent.files.first().absolutePath}" - jvmArgs "-Dotel.javaagent.experimental.initializer.jar=${shadowJar.archiveFile.get().asFile.absolutePath}" - jvmArgs "-Dotel.javaagent.testing.additional-library-ignores.enabled=false" - jvmArgs "-Dotel.javaagent.testing.fail-on-context-leak=true" - // prevent sporadic gradle deadlocks, see SafeLogger for more details - jvmArgs "-Dotel.javaagent.testing.transform-safe-logging.enabled=true" - jvmArgs "-Dotel.metrics.exporter=otlp" - - dependsOn shadowJar - dependsOn configurations.testAgent.buildDependencies - - // The sources are packaged into the testing jar so we need to make sure to exclude from the test - // classpath, which automatically inherits them, to ensure our shaded versions are used. - classpath = classpath.filter { - if (it == file("$buildDir/resources/main") || it == file("$buildDir/classes/java/main")) { - return false - } - return true - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03d81c74..a4e10307 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -shadow = "8.1.1" +shadow = "8.3.5" jib = "3.4.4" spotless = "7.0.1" junit = "5.11.4" @@ -22,6 +22,9 @@ opentelemetryContribAlpha = "1.42.0-alpha" # reference the "incubating" version explicitly opentelemetrySemconvAlpha = "1.29.0-alpha" +# instrumented libraries +openaiClient = "0.11.0" + [libraries] # transitively provides 'opentelemetry-instrumentation-bom' (non-alpha) @@ -44,6 +47,7 @@ autoservice-annotations = { group = "com.google.auto.service", name = "auto-serv assertj-core = "org.assertj:assertj-core:3.27.2" awaitility = "org.awaitility:awaitility:4.2.2" findbugs-jsr305 = "com.google.code.findbugs:jsr305:3.0.2" +wiremock = "com.github.tomakehurst:wiremock-jre8:2.35.2" testcontainers = "org.testcontainers:testcontainers:1.20.4" logback = "ch.qos.logback:logback-classic:1.5.16" jackson = "com.fasterxml.jackson.core:jackson-databind:2.18.2" @@ -66,13 +70,18 @@ asyncprofiler = "tools.profiler:async-profiler:3.0" freemarker = "org.freemarker:freemarker:2.3.34" spotlessPlugin = { group = "com.diffplug.spotless", name = "spotless-plugin-gradle", version.ref = "spotless" } -shadowPlugin = { group = "com.github.johnrengelman", name = "shadow", version.ref = "shadow" } +shadowPlugin = { group = "com.gradleup.shadow", name = "shadow-gradle-plugin", version.ref = "shadow" } licenseReportPlugin = "com.github.jk1.dependency-license-report:com.github.jk1.dependency-license-report.gradle.plugin:2.9" +muzzleCheckPlugin = { group = "io.opentelemetry.instrumentation.muzzle-check", name = "io.opentelemetry.instrumentation.muzzle-check.gradle.plugin", version.ref = "opentelemetryJavaagentAlpha" } +muzzleGenerationPlugin = { group = "io.opentelemetry.instrumentation.muzzle-generation", name = "io.opentelemetry.instrumentation.muzzle-generation.gradle.plugin", version.ref = "opentelemetryJavaagentAlpha" } # Ant should be kept in sync with the version used in the shadow plugin ant = "org.apache.ant:ant:1.10.15" # ASM is currently only used during compile-time, so it is okay to diverge from the version used in ByteBuddy asm = "org.ow2.asm:asm:9.7" +# Instrumented libraries +openaiClient = {group = "com.openai", name = "openai-java", version.ref ="openaiClient"} + [bundles] semconv = ["opentelemetrySemconv", "opentelemetrySemconvIncubating"] @@ -84,4 +93,3 @@ taskinfo = { id = "org.barfuin.gradle.taskinfo", version = '2.2.0' } jmh = {id = "me.champeau.jmh", version = "0.7.2"} nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version = '2.0.0' } dockerJavaApplication = { id = "com.bmuschko.docker-java-application", version = "9.4.0" } -shadow = { id = "com.github.johnrengelman.shadow", version.ref = "shadow" } diff --git a/instrumentation/build.gradle b/instrumentation/build.gradle deleted file mode 100644 index e49dd1b1..00000000 --- a/instrumentation/build.gradle +++ /dev/null @@ -1,15 +0,0 @@ - -// TODO : migrate this to kotlin for consistency -Project instr_project = project -subprojects { - afterEvaluate { Project subProj -> - if (subProj.getPlugins().hasPlugin('java')) { - // Make it so all instrumentation subproject tests can be run with a single command. - instr_project.tasks.test.dependsOn(subProj.tasks.test) - - instr_project.dependencies { - implementation(project(subProj.getPath())) - } - } - } -} diff --git a/instrumentation/openai-client-instrumentation/build.gradle.kts b/instrumentation/openai-client-instrumentation/build.gradle.kts new file mode 100644 index 00000000..3a550c91 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("elastic-otel.instrumentation-conventions") +} + +dependencies { + compileOnly(catalog.openaiClient) + testImplementation(catalog.openaiClient) + + testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2") + testImplementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.2") + testImplementation("org.slf4j:slf4j-simple:2.0.16") + testImplementation(catalog.wiremock) +} + +muzzle { + // TODO: setup muzzle to check older versions of openAI client + // See the docs on how to do it: + // https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/contributing/muzzle.md +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/OpenAiClientInstrumentationModule.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/OpenAiClientInstrumentationModule.java new file mode 100644 index 00000000..5e87fe7e --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/OpenAiClientInstrumentationModule.java @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import co.elastic.otel.openai.wrappers.Constants; +import com.google.auto.service.AutoService; +import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import java.util.Collections; +import java.util.List; + +@AutoService(InstrumentationModule.class) +public class OpenAiClientInstrumentationModule extends InstrumentationModule { + + public OpenAiClientInstrumentationModule() { + super(Constants.INSTRUMENTATION_NAME); + } + + @Override + public List typeInstrumentations() { + return Collections.singletonList(new OpenAiOkHttpClientBuilderInstrumentation()); + } + + @Override + public boolean isHelperClass(String className) { + return className.startsWith("co.elastic.otel.openai"); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/OpenAiOkHttpClientBuilderInstrumentation.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/OpenAiOkHttpClientBuilderInstrumentation.java new file mode 100644 index 00000000..17504559 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/OpenAiOkHttpClientBuilderInstrumentation.java @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.returns; + +import co.elastic.otel.openai.wrappers.InstrumentedOpenAiClient; +import com.openai.client.OpenAIClient; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +public class OpenAiOkHttpClientBuilderInstrumentation implements TypeInstrumentation { + + @Override + public ElementMatcher typeMatcher() { + return named("com.openai.client.okhttp.OpenAIOkHttpClient$Builder"); + } + + @Override + public void transform(TypeTransformer typeTransformer) { + typeTransformer.applyAdviceToMethod( + named("build").and(returns(named("com.openai.client.OpenAIClient"))), + "co.elastic.otel.openai.OpenAiOkHttpClientBuilderInstrumentation$AdviceClass"); + } + + public static class AdviceClass { + + @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class) + @Advice.AssignReturned.ToReturned + public static OpenAIClient onExit( + @Advice.Return OpenAIClient result, @Advice.FieldValue("baseUrl") String baseUrl) { + return InstrumentedOpenAiClient.wrap(result).baseUrl(baseUrl).build(); + } + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/ChatCompletionEventsHelper.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/ChatCompletionEventsHelper.java new file mode 100644 index 00000000..38d65dba --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/ChatCompletionEventsHelper.java @@ -0,0 +1,242 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_SYSTEM; + +import com.openai.models.ChatCompletion; +import com.openai.models.ChatCompletionAssistantMessageParam; +import com.openai.models.ChatCompletionContentPart; +import com.openai.models.ChatCompletionContentPartText; +import com.openai.models.ChatCompletionCreateParams; +import com.openai.models.ChatCompletionMessage; +import com.openai.models.ChatCompletionMessageParam; +import com.openai.models.ChatCompletionMessageToolCall; +import com.openai.models.ChatCompletionSystemMessageParam; +import com.openai.models.ChatCompletionToolMessageParam; +import com.openai.models.ChatCompletionUserMessageParam; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.api.logs.LogRecordBuilder; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.context.Context; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class ChatCompletionEventsHelper { + + private static final Logger EV_LOGGER = + GlobalOpenTelemetry.get().getLogsBridge().get(Constants.INSTRUMENTATION_NAME); + + private static final AttributeKey EVENT_NAME_KEY = AttributeKey.stringKey("event.name"); + + public static void emitPromptLogEvents( + ChatCompletionCreateParams request, InstrumentationSettings settings) { + if (!settings.emitEvents) { + return; + } + for (ChatCompletionMessageParam msg : request.messages()) { + String eventType; + MapValueBuilder bodyBuilder = new MapValueBuilder(); + if (msg.isChatCompletionSystemMessageParam()) { + ChatCompletionSystemMessageParam sysMsg = msg.asChatCompletionSystemMessageParam(); + eventType = "gen_ai.system.message"; + if (settings.captureMessageContent) { + putIfNotEmpty(bodyBuilder, "content", contentToString(sysMsg.content())); + } + } else if (msg.isChatCompletionUserMessageParam()) { + ChatCompletionUserMessageParam userMsg = msg.asChatCompletionUserMessageParam(); + eventType = "gen_ai.user.message"; + if (settings.captureMessageContent) { + putIfNotEmpty(bodyBuilder, "content", contentToString(userMsg.content())); + } + } else if (msg.isChatCompletionAssistantMessageParam()) { + ChatCompletionAssistantMessageParam assistantMsg = + msg.asChatCompletionAssistantMessageParam(); + eventType = "gen_ai.assistant.message"; + if (settings.captureMessageContent) { + assistantMsg + .content() + .ifPresent( + content -> putIfNotEmpty(bodyBuilder, "content", contentToString(content))); + assistantMsg + .toolCalls() + .ifPresent( + toolCalls -> { + List> toolCallsJson = + toolCalls.stream() + .map(ChatCompletionEventsHelper::buildToolCallEventObject) + .collect(Collectors.toList()); + bodyBuilder.put("tool_calls", Value.of(toolCallsJson)); + }); + } + } else if (msg.isChatCompletionToolMessageParam()) { + ChatCompletionToolMessageParam toolMsg = msg.asChatCompletionToolMessageParam(); + eventType = "gen_ai.tool.message"; + if (settings.captureMessageContent) { + putIfNotEmpty(bodyBuilder, "content", contentToString(toolMsg.content())); + bodyBuilder.put("id", toolMsg.toolCallId()); + } + } else { + throw new IllegalStateException("Unhandled type : " + msg.getClass().getName()); + } + newEvent(eventType).setBody(bodyBuilder.build()).emit(); + } + } + + private static void putIfNotEmpty(MapValueBuilder bodyBuilder, String key, String value) { + if (value != null && !value.isEmpty()) { + bodyBuilder.put(key, value); + } + } + + private static String contentToString(ChatCompletionToolMessageParam.Content content) { + if (content.isTextContent()) { + return content.asTextContent(); + } else if (content.isArrayOfContentParts()) { + return content.asArrayOfContentParts().stream() + .map(ChatCompletionContentPartText::text) + .collect(Collectors.joining()); + } else { + throw new IllegalStateException("Unhandled content type for " + content); + } + } + + private static String contentToString(ChatCompletionAssistantMessageParam.Content content) { + if (content.isTextContent()) { + return content.asTextContent(); + } else if (content.isArrayOfContentParts()) { + return content.asArrayOfContentParts().stream() + .map( + cnt -> { + if (cnt.isChatCompletionContentPartText()) { + return cnt.asChatCompletionContentPartText().text(); + } else if (cnt.isChatCompletionContentPartRefusal()) { + return cnt.asChatCompletionContentPartRefusal().refusal(); + } + return ""; + }) + .collect(Collectors.joining()); + } else { + throw new IllegalStateException("Unhandled content type for " + content); + } + } + + private static String contentToString(ChatCompletionSystemMessageParam.Content content) { + if (content.isTextContent()) { + return content.asTextContent(); + } else if (content.isArrayOfContentParts()) { + return content.asArrayOfContentParts().stream() + .map(ChatCompletionContentPartText::text) + .collect(Collectors.joining()); + } else { + throw new IllegalStateException("Unhandled content type for " + content); + } + } + + private static String contentToString(ChatCompletionUserMessageParam.Content content) { + if (content.isTextContent()) { + return content.asTextContent(); + } else if (content.isArrayOfContentParts()) { + return content.asArrayOfContentParts().stream() + .filter(ChatCompletionContentPart::isChatCompletionContentPartText) + .map(ChatCompletionContentPart::asChatCompletionContentPartText) + .map(ChatCompletionContentPartText::text) + .collect(Collectors.joining()); + } else { + throw new IllegalStateException("Unhandled content type for " + content); + } + } + + public static void emitCompletionLogEvents( + ChatCompletion completion, InstrumentationSettings settings) { + if (!settings.emitEvents) { + return; + } + for (ChatCompletion.Choice choice : completion.choices()) { + ChatCompletionMessage choiceMsg = choice.message(); + Map> message = new HashMap<>(); + if (settings.captureMessageContent) { + choiceMsg.content().ifPresent(content -> message.put("content", Value.of(content))); + choiceMsg + .toolCalls() + .ifPresent( + toolCalls -> { + message.put( + "tool_calls", + Value.of( + toolCalls.stream() + .map(ChatCompletionEventsHelper::buildToolCallEventObject) + .collect(Collectors.toList()))); + }); + } + emitCompletionLogEvent( + choice.index(), choice.finishReason().toString(), Value.of(message), settings, null); + } + } + + public static void emitCompletionLogEvent( + long index, + String finishReason, + Value eventMessageObject, + InstrumentationSettings settings, + Context contextOverride) { + if (!settings.emitEvents) { + return; + } + LogRecordBuilder builder = + newEvent("gen_ai.choice") + .setBody( + new MapValueBuilder() + .put("finish_reason", finishReason) + .put("index", index) + .put("message", eventMessageObject) + .build()); + if (contextOverride != null) { + builder.setContext(contextOverride); + } + builder.emit(); + } + + private static LogRecordBuilder newEvent(String name) { + return EV_LOGGER + .logRecordBuilder() + .setAttribute(EVENT_NAME_KEY, name) + .setAttribute(GEN_AI_SYSTEM, "openai"); + } + + private static Value buildToolCallEventObject(ChatCompletionMessageToolCall call) { + Map> result = new HashMap<>(); + result.put("id", Value.of(call.id())); + result.put("type", Value.of(call.type().toString())); + result.put("function", buildFunctionEventObject(call.function())); + return Value.of(result); + } + + private static Value buildFunctionEventObject( + ChatCompletionMessageToolCall.Function function) { + Map> result = new HashMap<>(); + result.put("name", Value.of(function.name())); + result.put("arguments", Value.of(function.arguments())); + return Value.of(result); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/Constants.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/Constants.java new file mode 100644 index 00000000..5f322482 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/Constants.java @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +public class Constants { + + public static final String INSTRUMENTATION_NAME = "openai-client"; +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/DelegatingInvocationHandler.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/DelegatingInvocationHandler.java new file mode 100644 index 00000000..4c969e72 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/DelegatingInvocationHandler.java @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +public abstract class DelegatingInvocationHandler> + implements InvocationHandler { + + private static final ClassLoader CLASS_LOADER = + DelegatingInvocationHandler.class.getClassLoader(); + + protected final T delegate; + + public DelegatingInvocationHandler(T delegate) { + this.delegate = delegate; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + try { + return method.invoke(delegate, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + protected abstract Class getProxyType(); + + public T createProxy() { + Class proxyType = getProxyType(); + Object proxy = Proxy.newProxyInstance(CLASS_LOADER, new Class[] {proxyType}, this); + return proxyType.cast(proxy); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/EventLoggingStreamedResponse.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/EventLoggingStreamedResponse.java new file mode 100644 index 00000000..583d78d8 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/EventLoggingStreamedResponse.java @@ -0,0 +1,80 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import com.openai.core.http.StreamResponse; +import com.openai.models.ChatCompletionChunk; +import io.opentelemetry.context.Context; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +public class EventLoggingStreamedResponse implements StreamResponse { + + private final StreamResponse delegate; + + private final InstrumentationSettings settings; + + private final Context originalContext; + + EventLoggingStreamedResponse( + StreamResponse delegate, InstrumentationSettings settings) { + this.delegate = delegate; + this.settings = settings; + this.originalContext = Context.current(); + } + + /** Key is the choice index */ + private final Map choiceBuffers = new HashMap<>(); + + @Override + public Stream stream() { + return delegate.stream() + .filter( + chunk -> { + onChunkReceive(chunk); + return true; + }); + } + + private void onChunkReceive(ChatCompletionChunk completionMessage) { + for (ChatCompletionChunk.Choice choice : completionMessage.choices()) { + long choiceIndex = choice.index(); + StreamedMessageBuffer msg = + choiceBuffers.computeIfAbsent(choiceIndex, _i -> new StreamedMessageBuffer()); + msg.append(choice.delta()); + + if (choice.finishReason().isPresent()) { + // message has ended, let's emit + ChatCompletionEventsHelper.emitCompletionLogEvent( + choiceIndex, + choice.finishReason().get().toString(), + msg.toEventMessageObject(settings), + settings, + originalContext); + choiceBuffers.remove(choiceIndex); + } + } + } + + @Override + public void close() throws Exception { + delegate.close(); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/GenAiAttributes.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/GenAiAttributes.java new file mode 100644 index 00000000..95421f8e --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/GenAiAttributes.java @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import io.opentelemetry.api.common.AttributeKey; +import java.util.List; + +public class GenAiAttributes { + + public static final AttributeKey GEN_AI_OPERATION_NAME = + AttributeKey.stringKey("gen_ai.operation.name"); + public static final AttributeKey GEN_AI_REQUEST_FREQUENCY_PENALTY = + AttributeKey.doubleKey("gen_ai.request.frequency_penalty"); + public static final AttributeKey GEN_AI_REQUEST_MAX_TOKENS = + AttributeKey.longKey("gen_ai.request.max_tokens"); + public static final AttributeKey GEN_AI_REQUEST_MODEL = + AttributeKey.stringKey("gen_ai.request.model"); + public static final AttributeKey GEN_AI_REQUEST_PRESENCE_PENALTY = + AttributeKey.doubleKey("gen_ai.request.presence_penalty"); + public static final AttributeKey> GEN_AI_REQUEST_STOP_SEQUENCES = + AttributeKey.stringArrayKey("gen_ai.request.stop_sequences"); + public static final AttributeKey GEN_AI_REQUEST_TEMPERATURE = + AttributeKey.doubleKey("gen_ai.request.temperature"); + public static final AttributeKey GEN_AI_REQUEST_TOP_P = + AttributeKey.doubleKey("gen_ai.request.top_p"); + public static final AttributeKey> GEN_AI_RESPONSE_FINISH_REASONS = + AttributeKey.stringArrayKey("gen_ai.response.finish_reasons"); + public static final AttributeKey GEN_AI_RESPONSE_ID = + AttributeKey.stringKey("gen_ai.response.id"); + public static final AttributeKey GEN_AI_RESPONSE_MODEL = + AttributeKey.stringKey("gen_ai.response.model"); + public static final AttributeKey GEN_AI_SYSTEM = AttributeKey.stringKey("gen_ai.system"); + public static final AttributeKey GEN_AI_USAGE_INPUT_TOKENS = + AttributeKey.longKey("gen_ai.usage.input_tokens"); + public static final AttributeKey GEN_AI_USAGE_OUTPUT_TOKENS = + AttributeKey.longKey("gen_ai.usage.output_tokens"); + public static final AttributeKey GEN_AI_TOKEN_TYPE = + AttributeKey.stringKey("gen_ai.token.type"); + public static final AttributeKey> GEN_AI_REQUEST_ENCODING_FORMATS = + AttributeKey.stringArrayKey("gen_ai.request.encoding_formats"); + + public static final AttributeKey GEN_AI_OPENAI_REQUEST_SEED = + AttributeKey.longKey("gen_ai.openai.request.seed"); + public static final AttributeKey GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT = + AttributeKey.stringKey("gen_ai.openai.request.response_format"); + + public static final AttributeKey SERVER_ADDRESS = + AttributeKey.stringKey("server.address"); + public static final AttributeKey SERVER_PORT = AttributeKey.longKey("server.port"); + + public static final AttributeKey ERROR_TYPE = AttributeKey.stringKey("error.type"); + + public static final String TOKEN_TYPE_INPUT = "input"; + public static final String TOKEN_TYPE_OUTPUT = "output"; +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/GenAiClientMetrics.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/GenAiClientMetrics.java new file mode 100644 index 00000000..1314951d --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/GenAiClientMetrics.java @@ -0,0 +1,186 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import static co.elastic.otel.openai.wrappers.GenAiAttributes.ERROR_TYPE; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_OPERATION_NAME; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_MODEL; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_RESPONSE_MODEL; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_SYSTEM; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_TOKEN_TYPE; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_USAGE_INPUT_TOKENS; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_USAGE_OUTPUT_TOKENS; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.SERVER_ADDRESS; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.SERVER_PORT; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.TOKEN_TYPE_INPUT; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.TOKEN_TYPE_OUTPUT; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongHistogram; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.ContextKey; +import io.opentelemetry.instrumentation.api.instrumenter.OperationListener; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Defines duration and token usage metrics using histogram buckets defined here: + * https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/gen-ai/gen-ai-metrics.md + */ +final class GenAiClientMetrics implements OperationListener { + + // Visible for testing + static final double NANOS_PER_S = TimeUnit.SECONDS.toNanos(1); + + private static final ContextKey GEN_AI_CLIENT_METRICS_STATE = + ContextKey.named("genai-client-metrics-state"); + + private static final String METRIC_GEN_AI_CLIENT_OPERATION_DURATION = + "gen_ai.client.operation.duration"; + private static final String METRIC_GEN_AI_CLIENT_TOKEN_USAGE = "gen_ai.client.token.usage"; + + private static final List clientOperationBuckets = getClientOperationBuckets(); + private static final List clientTokenBuckets = getClientTokenUsageBuckets(); + + private final DoubleHistogram clientOperationDuration; + private final LongHistogram clientTokenUsage; + + GenAiClientMetrics(Meter meter) { + clientOperationDuration = + meter + .histogramBuilder(METRIC_GEN_AI_CLIENT_OPERATION_DURATION) + .setDescription("GenAI operation duration") + .setUnit("s") + .setExplicitBucketBoundariesAdvice(clientOperationBuckets) + .build(); + clientTokenUsage = + meter + .histogramBuilder(METRIC_GEN_AI_CLIENT_TOKEN_USAGE) + .ofLongs() + .setDescription("Measures number of input and output tokens used") + .setUnit("{token}") + .setExplicitBucketBoundariesAdvice(clientTokenBuckets) + .build(); + } + + @Override + public Context onStart(Context context, Attributes startAttributes, long startNanos) { + return context.with(GEN_AI_CLIENT_METRICS_STATE, new State(startAttributes, startNanos)); + } + + @Override + public void onEnd(Context context, Attributes endAttributes, long endNanos) { + State state = context.get(GEN_AI_CLIENT_METRICS_STATE); + if (state == null) { + return; + } + AttributesBuilder commonBuilder = Attributes.builder(); + copyAttribute(state.startAttributes, commonBuilder, SERVER_PORT); + copyAttribute(state.startAttributes, commonBuilder, SERVER_ADDRESS); + copyAttribute(state.startAttributes, commonBuilder, GEN_AI_OPERATION_NAME); + copyAttribute(state.startAttributes, commonBuilder, GEN_AI_SYSTEM); + copyAttribute(state.startAttributes, commonBuilder, GEN_AI_REQUEST_MODEL); + copyAttribute(endAttributes, commonBuilder, GEN_AI_RESPONSE_MODEL); + Attributes common = commonBuilder.build(); + + AttributesBuilder durationAttributesBuilder = common.toBuilder(); + copyAttribute(endAttributes, durationAttributesBuilder, ERROR_TYPE); + clientOperationDuration.record( + (endNanos - state.startTimeNanos) / NANOS_PER_S, + durationAttributesBuilder.build(), + context); + + Long inputTokens = endAttributes.get(GEN_AI_USAGE_INPUT_TOKENS); + if (inputTokens != null) { + clientTokenUsage.record( + inputTokens, + common.toBuilder().put(GEN_AI_TOKEN_TYPE, TOKEN_TYPE_INPUT).build(), + context); + } + Long outputTokens = endAttributes.get(GEN_AI_USAGE_OUTPUT_TOKENS); + if (outputTokens != null) { + clientTokenUsage.record( + outputTokens, + common.toBuilder().put(GEN_AI_TOKEN_TYPE, TOKEN_TYPE_OUTPUT).build(), + context); + } + } + + private static void copyAttribute( + Attributes from, AttributesBuilder to, AttributeKey key) { + T value = from.get(key); + if (value != null) { + to.put(key, value); + } + } + + private static class State { + final Attributes startAttributes; + final long startTimeNanos; + + private State(Attributes startAttributes, long startTimeNanos) { + this.startAttributes = startAttributes; + this.startTimeNanos = startTimeNanos; + } + } + + private static List getClientOperationBuckets() { + List clientOperationBuckets = new ArrayList<>(); + clientOperationBuckets.add(0.01); + clientOperationBuckets.add(0.02); + clientOperationBuckets.add(0.04); + clientOperationBuckets.add(0.08); + clientOperationBuckets.add(0.16); + clientOperationBuckets.add(0.32); + clientOperationBuckets.add(0.64); + clientOperationBuckets.add(1.28); + clientOperationBuckets.add(2.56); + clientOperationBuckets.add(5.12); + clientOperationBuckets.add(10.24); + clientOperationBuckets.add(20.48); + clientOperationBuckets.add(40.96); + clientOperationBuckets.add(81.92); + return Collections.unmodifiableList(clientOperationBuckets); + } + + private static List getClientTokenUsageBuckets() { + List clientTokenUsageBuckets = new ArrayList<>(); + clientTokenUsageBuckets.add(1L); + clientTokenUsageBuckets.add(4L); + clientTokenUsageBuckets.add(16L); + clientTokenUsageBuckets.add(64L); + clientTokenUsageBuckets.add(256L); + clientTokenUsageBuckets.add(1024L); + clientTokenUsageBuckets.add(4096L); + clientTokenUsageBuckets.add(16384L); + clientTokenUsageBuckets.add(65536L); + clientTokenUsageBuckets.add(262144L); + clientTokenUsageBuckets.add(1048576L); + clientTokenUsageBuckets.add(4194304L); + clientTokenUsageBuckets.add(16777216L); + clientTokenUsageBuckets.add(67108864L); + return Collections.unmodifiableList(clientTokenUsageBuckets); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentationSettings.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentationSettings.java new file mode 100644 index 00000000..15b9180c --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentationSettings.java @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import static co.elastic.otel.openai.wrappers.GenAiAttributes.SERVER_ADDRESS; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.SERVER_PORT; + +import io.opentelemetry.api.common.AttributesBuilder; + +public class InstrumentationSettings { + + final boolean emitEvents; + final boolean captureMessageContent; + + // we do not directly have access to the client baseUrl after construction, therefore we need to + // remember it + // visible for testing + final String serverAddress; + final Long serverPort; + + InstrumentationSettings( + boolean emitEvents, boolean captureMessageContent, String serverAddress, Long serverPort) { + this.emitEvents = emitEvents; + this.captureMessageContent = captureMessageContent; + this.serverAddress = serverAddress; + this.serverPort = serverPort; + } + + public void putServerInfoIntoAttributes(AttributesBuilder attributes) { + if (serverAddress != null) { + attributes.put(SERVER_ADDRESS, serverAddress); + } + if (serverPort != null) { + attributes.put(SERVER_PORT, serverPort); + } + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedChatCompletionService.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedChatCompletionService.java new file mode 100644 index 00000000..bb7b8dbf --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedChatCompletionService.java @@ -0,0 +1,268 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import static co.elastic.otel.openai.wrappers.GenAiAttributes.ERROR_TYPE; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_OPENAI_REQUEST_SEED; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_OPERATION_NAME; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_FREQUENCY_PENALTY; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_MAX_TOKENS; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_MODEL; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_PRESENCE_PENALTY; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_STOP_SEQUENCES; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_TEMPERATURE; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_TOP_P; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_RESPONSE_FINISH_REASONS; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_RESPONSE_ID; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_RESPONSE_MODEL; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_SYSTEM; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_USAGE_INPUT_TOKENS; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_USAGE_OUTPUT_TOKENS; + +import com.google.errorprone.annotations.MustBeClosed; +import com.openai.core.RequestOptions; +import com.openai.core.http.StreamResponse; +import com.openai.models.ChatCompletion; +import com.openai.models.ChatCompletionChunk; +import com.openai.models.ChatCompletionCreateParams; +import com.openai.models.CompletionUsage; +import com.openai.services.blocking.chat.CompletionService; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +public class InstrumentedChatCompletionService implements CompletionService { + + public static class RequestHolder { + final ChatCompletionCreateParams request; + final InstrumentationSettings settings; + + public RequestHolder(ChatCompletionCreateParams request, InstrumentationSettings settings) { + this.request = request; + this.settings = settings; + } + } + + public static class ChatCompletionResult { + private final String responseModel; + private final String responseId; + private final List finishReasons; + + private final Long inputTokens; + private final Long completionTokens; + + public ChatCompletionResult( + String responseModel, + String responseId, + List finishReasons, + Long inputTokens, + Long completionTokens) { + this.responseModel = responseModel; + this.responseId = responseId; + this.finishReasons = finishReasons; + this.inputTokens = inputTokens; + this.completionTokens = completionTokens; + } + } + + static final Instrumenter INSTRUMENTER = + Instrumenter.builder( + GlobalOpenTelemetry.get(), + Constants.INSTRUMENTATION_NAME, + holder -> "chat " + holder.request.model()) + .addAttributesExtractor( + new AttributesExtractor() { + @Override + public void onStart( + AttributesBuilder attributes, + Context parentContext, + RequestHolder requestHolder) { + ChatCompletionCreateParams request = requestHolder.request; + + requestHolder.settings.putServerInfoIntoAttributes(attributes); + attributes.put(GEN_AI_SYSTEM, "openai"); + attributes.put(GEN_AI_OPERATION_NAME, "chat"); + attributes.put(GEN_AI_REQUEST_MODEL, request.model().toString()); + request + .frequencyPenalty() + .ifPresent(val -> attributes.put(GEN_AI_REQUEST_FREQUENCY_PENALTY, val)); + request + .maxTokens() + .ifPresent(val -> attributes.put(GEN_AI_REQUEST_MAX_TOKENS, val)); + request + .presencePenalty() + .ifPresent(val -> attributes.put(GEN_AI_REQUEST_PRESENCE_PENALTY, val)); + request + .temperature() + .ifPresent(val -> attributes.put(GEN_AI_REQUEST_TEMPERATURE, val)); + request.topP().ifPresent(val -> attributes.put(GEN_AI_REQUEST_TOP_P, val)); + request.seed().ifPresent(val -> attributes.put(GEN_AI_OPENAI_REQUEST_SEED, val)); + request + .stop() + .ifPresent( + stop -> { + if (stop.isString()) { + attributes.put( + GEN_AI_REQUEST_STOP_SEQUENCES, + Collections.singletonList(stop.asString())); + } else if (stop.isStrings()) { + attributes.put(GEN_AI_REQUEST_STOP_SEQUENCES, stop.asStrings()); + } + }); + request + .responseFormat() + .ifPresent( + val -> { + if (val.isResponseFormatText()) { + attributes.put( + GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT, + val.asResponseFormatText().type().toString()); + } + }); + } + + @Override + public void onEnd( + AttributesBuilder attributes, + Context context, + RequestHolder request, + ChatCompletionResult result, + Throwable error) { + if (error != null) { + attributes.put(ERROR_TYPE, error.getClass().getCanonicalName()); + } else { + attributes.put(GEN_AI_RESPONSE_MODEL, result.responseModel); + attributes.put(GEN_AI_RESPONSE_ID, result.responseId); + + List finishReasons = result.finishReasons; + if (finishReasons != null && !finishReasons.isEmpty()) { + attributes.put(GEN_AI_RESPONSE_FINISH_REASONS, finishReasons); + } + + if (result.inputTokens != null) { + attributes.put(GEN_AI_USAGE_INPUT_TOKENS, result.inputTokens); + } + if (result.completionTokens != null) { + attributes.put(GEN_AI_USAGE_OUTPUT_TOKENS, result.completionTokens); + } + } + } + }) + .addOperationMetrics(GenAiClientMetrics::new) + .buildInstrumenter(); + + private final CompletionService delegate; + private final InstrumentationSettings settings; + + InstrumentedChatCompletionService(CompletionService delegate, InstrumentationSettings settings) { + this.delegate = delegate; + this.settings = settings; + } + + @Override + public ChatCompletion create( + ChatCompletionCreateParams chatCompletionCreateParams, RequestOptions requestOptions) { + + RequestHolder requestHolder = new RequestHolder(chatCompletionCreateParams, settings); + + Context parentCtx = Context.current(); + if (!INSTRUMENTER.shouldStart(parentCtx, requestHolder)) { + return createWithLogs(chatCompletionCreateParams, requestOptions); + } + + Context ctx = INSTRUMENTER.start(parentCtx, requestHolder); + ChatCompletion completion; + try (Scope scope = ctx.makeCurrent()) { + completion = createWithLogs(chatCompletionCreateParams, requestOptions); + } catch (Throwable t) { + INSTRUMENTER.end(ctx, requestHolder, null, t); + throw t; + } + + List finishReasons = + completion.choices().stream() + .map(ChatCompletion.Choice::finishReason) + .map(ChatCompletion.Choice.FinishReason::toString) + .collect(Collectors.toList()); + Long inputTokens = null; + Long completionTokens = null; + Optional usage = completion.usage(); + if (usage.isPresent()) { + inputTokens = usage.get().promptTokens(); + completionTokens = usage.get().completionTokens(); + } + ChatCompletionResult result = + new ChatCompletionResult( + completion.model(), completion.id(), finishReasons, inputTokens, completionTokens); + INSTRUMENTER.end(ctx, requestHolder, result, null); + return completion; + } + + private ChatCompletion createWithLogs( + ChatCompletionCreateParams chatCompletionCreateParams, RequestOptions requestOptions) { + ChatCompletionEventsHelper.emitPromptLogEvents(chatCompletionCreateParams, settings); + ChatCompletion result = delegate.create(chatCompletionCreateParams, requestOptions); + ChatCompletionEventsHelper.emitCompletionLogEvents(result, settings); + return result; + } + + @MustBeClosed + @Override + public StreamResponse createStreaming( + ChatCompletionCreateParams chatCompletionCreateParams, RequestOptions requestOptions) { + RequestHolder requestHolder = new RequestHolder(chatCompletionCreateParams, settings); + + Context parentCtx = Context.current(); + if (!INSTRUMENTER.shouldStart(parentCtx, requestHolder)) { + return createStreamingWithLogs(chatCompletionCreateParams, requestOptions); + } + + Context ctx = INSTRUMENTER.start(parentCtx, requestHolder); + StreamResponse response; + try (Scope scope = ctx.makeCurrent()) { + response = createStreamingWithLogs(chatCompletionCreateParams, requestOptions); + } catch (Throwable t) { + INSTRUMENTER.end(ctx, requestHolder, null, t); + throw t; + } + + TracingStreamedResponse wrappedResponse = + new TracingStreamedResponse(response, ctx, requestHolder); + return wrappedResponse; + } + + private StreamResponse createStreamingWithLogs( + ChatCompletionCreateParams chatCompletionCreateParams, RequestOptions requestOptions) { + ChatCompletionEventsHelper.emitPromptLogEvents(chatCompletionCreateParams, settings); + StreamResponse result = + delegate.createStreaming(chatCompletionCreateParams, requestOptions); + if (settings.emitEvents) { + result = new EventLoggingStreamedResponse(result, settings); + } + return result; + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedChatService.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedChatService.java new file mode 100644 index 00000000..5c78541c --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedChatService.java @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import com.openai.services.blocking.ChatService; +import java.lang.reflect.Method; + +public class InstrumentedChatService + extends DelegatingInvocationHandler { + + private final InstrumentationSettings settings; + + public InstrumentedChatService(ChatService delegate, InstrumentationSettings settings) { + super(delegate); + this.settings = settings; + } + + @Override + protected Class getProxyType() { + return ChatService.class; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + String methodName = method.getName(); + Class[] parameterTypes = method.getParameterTypes(); + if (methodName.equals("completions") && parameterTypes.length == 0) { + return new InstrumentedChatCompletionService(delegate.completions(), settings); + } + return super.invoke(proxy, method, args); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedEmbeddingsService.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedEmbeddingsService.java new file mode 100644 index 00000000..696cb882 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedEmbeddingsService.java @@ -0,0 +1,126 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_OPERATION_NAME; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_ENCODING_FORMATS; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_REQUEST_MODEL; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_RESPONSE_MODEL; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_SYSTEM; +import static co.elastic.otel.openai.wrappers.GenAiAttributes.GEN_AI_USAGE_INPUT_TOKENS; + +import com.openai.core.RequestOptions; +import com.openai.models.CreateEmbeddingResponse; +import com.openai.models.EmbeddingCreateParams; +import com.openai.services.blocking.EmbeddingService; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.instrumentation.api.instrumenter.SpanKindExtractor; +import java.util.Collections; + +public class InstrumentedEmbeddingsService implements EmbeddingService { + + private final EmbeddingService delegate; + private final InstrumentationSettings settings; + + InstrumentedEmbeddingsService(EmbeddingService delegate, InstrumentationSettings settings) { + this.delegate = delegate; + this.settings = settings; + } + + private static class RequestHolder { + final EmbeddingCreateParams request; + final InstrumentationSettings settings; + + public RequestHolder(EmbeddingCreateParams request, InstrumentationSettings settings) { + this.request = request; + this.settings = settings; + } + } + + public static final Instrumenter INSTRUMENTER = + Instrumenter.builder( + GlobalOpenTelemetry.get(), + Constants.INSTRUMENTATION_NAME, + req -> "embeddings " + req.request.model()) + .addAttributesExtractor( + new AttributesExtractor() { + + @Override + public void onStart( + AttributesBuilder attributes, + Context parentContext, + RequestHolder requestHolder) { + EmbeddingCreateParams request = requestHolder.request; + requestHolder.settings.putServerInfoIntoAttributes(attributes); + attributes + .put(GEN_AI_SYSTEM, "openai") + .put(GEN_AI_OPERATION_NAME, "embeddings") + .put(GEN_AI_REQUEST_MODEL, request.model().toString()); + request + .encodingFormat() + .ifPresent( + format -> + attributes.put( + GEN_AI_REQUEST_ENCODING_FORMATS, + Collections.singletonList(format.toString()))); + } + + @Override + public void onEnd( + AttributesBuilder attributes, + Context context, + RequestHolder requestHolder, + CreateEmbeddingResponse embeddings, + Throwable error) { + if (embeddings != null) { + attributes.put(GEN_AI_USAGE_INPUT_TOKENS, embeddings.usage().promptTokens()); + attributes.put(GEN_AI_RESPONSE_MODEL, embeddings.model()); + } + } + }) + .buildInstrumenter(SpanKindExtractor.alwaysClient()); + + @Override + public CreateEmbeddingResponse create( + EmbeddingCreateParams embeddingCreateParams, RequestOptions requestOptions) { + RequestHolder requestHolder = new RequestHolder(embeddingCreateParams, settings); + + Context parentCtx = Context.current(); + if (!INSTRUMENTER.shouldStart(parentCtx, requestHolder)) { + return delegate.create(embeddingCreateParams, requestOptions); + } + + Context ctx = INSTRUMENTER.start(parentCtx, requestHolder); + CreateEmbeddingResponse response; + try (Scope scope = ctx.makeCurrent()) { + response = delegate.create(embeddingCreateParams, requestOptions); + } catch (Throwable t) { + INSTRUMENTER.end(ctx, requestHolder, null, t); + throw t; + } + + INSTRUMENTER.end(ctx, requestHolder, response, null); + return response; + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedOpenAiClient.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedOpenAiClient.java new file mode 100644 index 00000000..c111494f --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/InstrumentedOpenAiClient.java @@ -0,0 +1,122 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import com.openai.client.OpenAIClient; +import io.opentelemetry.api.internal.ConfigUtil; +import java.lang.reflect.Method; +import java.net.URI; + +public class InstrumentedOpenAiClient + extends DelegatingInvocationHandler { + + // Visible and non-final for testing + InstrumentationSettings settings; + + private InstrumentedOpenAiClient(OpenAIClient delegate, InstrumentationSettings settings) { + super(delegate); + this.settings = settings; + } + + @Override + protected Class getProxyType() { + return OpenAIClient.class; + } + + public static class Builder { + + private final OpenAIClient delegate; + + private boolean captureMessageContent = + Boolean.valueOf( + ConfigUtil.getString("otel.instrumentation.genai.capture.message.content", "false")); + + private boolean emitEvents = + Boolean.valueOf( + ConfigUtil.getString( + "elastic.otel.java.instrumentation.genai.emit.events", "" + captureMessageContent)); + + private String baseUrl; + + private Builder(OpenAIClient delegate) { + this.delegate = delegate; + } + + public Builder captureMessageContent(boolean captureMessageContent) { + this.captureMessageContent = captureMessageContent; + return this; + } + + public Builder emitEvents(boolean emitEvents) { + this.emitEvents = emitEvents; + return this; + } + + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public OpenAIClient build() { + if (delegate instanceof InstrumentedOpenAiClient) { + return delegate; + } + String hostname = null; + Long port = null; + if (baseUrl != null) { + try { + URI parsed = new URI(baseUrl); + hostname = parsed.getHost(); + int urlPort = parsed.getPort(); + if (urlPort != -1) { + port = (long) urlPort; + } else { + long defaultPort = parsed.toURL().getDefaultPort(); + if (defaultPort != -1) { + port = defaultPort; + } + } + } catch (Exception e) { + // ignore malformed + } + } + return new InstrumentedOpenAiClient( + delegate, + new InstrumentationSettings(emitEvents, captureMessageContent, hostname, port)) + .createProxy(); + } + } + + public static Builder wrap(OpenAIClient delegate) { + return new Builder(delegate); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + String methodName = method.getName(); + Class[] parameterTypes = method.getParameterTypes(); + if (methodName.equals("chat") && parameterTypes.length == 0) { + return new InstrumentedChatService(delegate.chat(), settings).createProxy(); + } + if (methodName.equals("embeddings") && parameterTypes.length == 0) { + return new InstrumentedEmbeddingsService(delegate.embeddings(), settings); + } + return super.invoke(proxy, method, args); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/MapValueBuilder.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/MapValueBuilder.java new file mode 100644 index 00000000..3b260f5b --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/MapValueBuilder.java @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import io.opentelemetry.api.common.Value; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Builder for fluently building a map-valued {@link Value}. */ +public class MapValueBuilder { + + private final Map> entries = new LinkedHashMap<>(); + + public MapValueBuilder put(String key, Value val) { + entries.put(key, val); + return this; + } + + public MapValueBuilder put(String key, String val) { + entries.put(key, Value.of(val)); + return this; + } + + public MapValueBuilder put(String key, long val) { + entries.put(key, Value.of(val)); + return this; + } + + public Value build() { + return Value.of(entries); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/StreamedMessageBuffer.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/StreamedMessageBuffer.java new file mode 100644 index 00000000..d4bf1f92 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/StreamedMessageBuffer.java @@ -0,0 +1,96 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import com.openai.models.ChatCompletionChunk; +import io.opentelemetry.api.common.Value; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +class StreamedMessageBuffer { + + private final StringBuilder message = new StringBuilder(); + + private final Map toolCalls = new HashMap<>(); + + public Value toEventMessageObject(InstrumentationSettings settings) { + MapValueBuilder attributes = new MapValueBuilder(); + if (settings.captureMessageContent && message.length() > 0) { + attributes.put("content", Value.of(message.toString())); + } + if (!toolCalls.isEmpty()) { + List> toolCallsJson = + toolCalls.values().stream() + .map(StreamedMessageBuffer::buildToolCallEventObject) + .collect(Collectors.toList()); + attributes.put("tool_calls", Value.of(toolCallsJson)); + } + return attributes.build(); + } + + public void append(ChatCompletionChunk.Choice.Delta delta) { + delta.content().ifPresent(message::append); + + if (delta.toolCalls().isPresent()) { + for (ChatCompletionChunk.Choice.Delta.ToolCall toolCall : delta.toolCalls().get()) { + ToolCallBuffer buffer = + toolCalls.computeIfAbsent( + toolCall.index(), unused -> new ToolCallBuffer(toolCall.id().get())); + toolCall.type().ifPresent(type -> buffer.type = type.toString()); + toolCall + .function() + .ifPresent( + function -> { + function.name().ifPresent(name -> buffer.function.name = name); + function.arguments().ifPresent(args -> buffer.function.arguments.append(args)); + }); + } + } + } + + private static Value buildToolCallEventObject(ToolCallBuffer call) { + Map> result = new HashMap<>(); + result.put("id", Value.of(call.id)); + result.put("type", Value.of(call.type)); + + Map> function = new HashMap<>(); + function.put("name", Value.of(call.function.name)); + function.put("arguments", Value.of(call.function.arguments.toString())); + result.put("function", Value.of(function)); + + return Value.of(result); + } + + private static class FunctionBuffer { + String name; + StringBuilder arguments = new StringBuilder(); + } + + private static class ToolCallBuffer { + ToolCallBuffer(String id) { + this.id = id; + } + + final String id; + final FunctionBuffer function = new FunctionBuffer(); + String type; + } +} diff --git a/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/TracingStreamedResponse.java b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/TracingStreamedResponse.java new file mode 100644 index 00000000..ad2799ff --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/main/java/co/elastic/otel/openai/wrappers/TracingStreamedResponse.java @@ -0,0 +1,159 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import com.openai.core.http.StreamResponse; +import com.openai.models.ChatCompletionChunk; +import com.openai.models.CompletionUsage; +import io.opentelemetry.context.Context; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Spliterator; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +public class TracingStreamedResponse implements StreamResponse { + + private final StreamResponse delegate; + private final Context spanContext; + private final InstrumentedChatCompletionService.RequestHolder requestHolder; + + private final Map choiceFinishReasons = new ConcurrentHashMap<>(); + private CompletionUsage usage; + private String model; + private String responseId; + private boolean hasEnded = false; + + public TracingStreamedResponse( + StreamResponse delegate, + Context spanContext, + InstrumentedChatCompletionService.RequestHolder requestHolder) { + this.delegate = delegate; + this.spanContext = spanContext; + this.requestHolder = requestHolder; + } + + @Override + public Stream stream() { + return StreamSupport.stream(new TracingSpliterator(delegate.stream().spliterator()), false); + } + + private void collectFinishReasons(ChatCompletionChunk chunk) { + for (ChatCompletionChunk.Choice choice : chunk.choices()) { + Optional finishReason = choice.finishReason(); + if (finishReason.isPresent()) { + choiceFinishReasons.put(choice.index(), finishReason.get().toString()); + } + } + } + + private synchronized void endSpan() { + if (hasEnded) { + return; + } + hasEnded = true; + + List finishReasons = + choiceFinishReasons.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(Map.Entry::getValue) + .collect(Collectors.toList()); + + Long inputTokens = null; + Long completionTokens = null; + if (usage != null) { + inputTokens = usage.promptTokens(); + completionTokens = usage.completionTokens(); + } + + InstrumentedChatCompletionService.ChatCompletionResult result = + new InstrumentedChatCompletionService.ChatCompletionResult( + model, responseId, finishReasons, inputTokens, completionTokens); + InstrumentedChatCompletionService.INSTRUMENTER.end(spanContext, requestHolder, result, null); + } + + @Override + public void close() throws Exception { + endSpan(); + delegate.close(); + } + + private class TracingSpliterator implements Spliterator { + + private final Spliterator delegateSpliterator; + + private TracingSpliterator(Spliterator delegateSpliterator) { + this.delegateSpliterator = delegateSpliterator; + } + + @Override + public boolean tryAdvance(Consumer action) { + boolean chunkReceived = + delegateSpliterator.tryAdvance( + chunk -> { + collectFinishReasons(chunk); + action.accept(chunk); + String model = chunk.model(); + if (model != null && !model.isEmpty()) { + TracingStreamedResponse.this.model = model; + } + String id = chunk.id(); + if (id != null && !id.isEmpty()) { + TracingStreamedResponse.this.responseId = id; + } + chunk.usage().ifPresent(usage -> TracingStreamedResponse.this.usage = usage); + }); + if (!chunkReceived) { + endSpan(); + } + return chunkReceived; + } + + @Override + public Spliterator trySplit() { + // do not support parallelism to reliably catch the last chunk + return null; + } + + @Override + public long estimateSize() { + return delegateSpliterator.estimateSize(); + } + + @Override + public long getExactSizeIfKnown() { + return delegateSpliterator.getExactSizeIfKnown(); + } + + @Override + public int characteristics() { + return delegateSpliterator.characteristics(); + } + + @Override + public Comparator getComparator() { + return delegateSpliterator.getComparator(); + } + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/ChatTest.java b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/ChatTest.java new file mode 100644 index 00000000..591e12f1 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/ChatTest.java @@ -0,0 +1,2381 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.semconv.ErrorAttributes.ERROR_TYPE; +import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; +import static io.opentelemetry.semconv.ServerAttributes.SERVER_PORT; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_OPENAI_REQUEST_SEED; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_OPERATION_NAME; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_FREQUENCY_PENALTY; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_MAX_TOKENS; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_MODEL; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_PRESENCE_PENALTY; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_STOP_SEQUENCES; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_TEMPERATURE; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_TOP_P; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_RESPONSE_FINISH_REASONS; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_RESPONSE_ID; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_RESPONSE_MODEL; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_SYSTEM; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_TOKEN_TYPE; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_USAGE_INPUT_TOKENS; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_USAGE_OUTPUT_TOKENS; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GenAiTokenTypeIncubatingValues.COMPLETION; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GenAiTokenTypeIncubatingValues.INPUT; + +import co.elastic.otel.openai.wrappers.Constants; +import co.elastic.otel.openai.wrappers.InstrumentationSettingsAccessor; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.core.JsonObject; +import com.openai.core.JsonValue; +import com.openai.core.http.StreamResponse; +import com.openai.errors.NotFoundException; +import com.openai.errors.OpenAIIoException; +import com.openai.models.ChatCompletion; +import com.openai.models.ChatCompletionAssistantMessageParam; +import com.openai.models.ChatCompletionChunk; +import com.openai.models.ChatCompletionCreateParams; +import com.openai.models.ChatCompletionMessageParam; +import com.openai.models.ChatCompletionMessageToolCall; +import com.openai.models.ChatCompletionStreamOptions; +import com.openai.models.ChatCompletionSystemMessageParam; +import com.openai.models.ChatCompletionTool; +import com.openai.models.ChatCompletionToolMessageParam; +import com.openai.models.ChatCompletionUserMessageParam; +import com.openai.models.FunctionDefinition; +import com.openai.models.FunctionParameters; +import com.openai.models.ResponseFormatText; +import io.opentelemetry.api.common.KeyValue; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.instrumentation.testing.junit.AgentInstrumentationExtension; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class ChatTest { + private static final String TEST_CHAT_MODEL = "gpt-4o-mini"; + private static final String TEST_CHAT_RESPONSE_MODEL = "gpt-4o-mini-2024-07-18"; + private static final String TEST_CHAT_INPUT = + "Answer in up to 3 words: Which ocean contains Bouvet Island?"; + + @RegisterExtension + static final AgentInstrumentationExtension testing = AgentInstrumentationExtension.create(); + + @RegisterExtension + static final OpenAIRecordingExtension openai = new OpenAIRecordingExtension("ChatTest"); + + @BeforeEach + void setup() { + // Default tests to capture content. + // TODO: Change test default to false. + InstrumentationSettingsAccessor.setCaptureMessageContent(openai.client, true); + // Default tests to enable events. + InstrumentationSettingsAccessor.setEmitEvents(openai.client, true); + } + + static Consumer assertThatDurationIsLessThan(long toNanos) { + double nanosPerSecond = Duration.ofSeconds(1).toNanos(); + return point -> assertThat(point.getSum()).isStrictlyBetween(0.0, toNanos / nanosPerSecond); + } + + private static void assertThatValueIsEmptyMap(Value value) { + assertThat(value.getValue()).isInstanceOfSatisfying(List.class, l -> assertThat(l).isEmpty()); + } + + private static void assertThatChoiceBodyHasNoContent(Value body, String finishReason) { + ValAssert.map().entry("index", 0).entry("finish_reason", finishReason).accept("", body); + Value message = + ((List) body.getValue()) + .stream().filter(kv -> kv.getKey().equals("message")).findFirst().get().getValue(); + assertThatValueIsEmptyMap(message); + } + + @Test + void chat() throws Exception { + InstrumentationSettingsAccessor.setCaptureMessageContent(openai.client, false); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .build(); + + long startTimeNanos = System.nanoTime(); + ChatCompletion chatCompletion = openai.client.chat().completions().create(params); + long durationNanos = System.nanoTime() - startTimeNanos; + chatCompletion.validate(); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_OPERATION_NAME, "chat") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsExactly("stop"); + }) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, 22L) + .hasAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, 3L) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + SpanContext spanCtx = spans.get(0).getSpanContext(); + assertThat(testing.logRecords()) + .hasSize(2) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.user.message")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies( + b -> { + assertThat(((List) b.getValue())).isEmpty(); + }); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.choice")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(b -> assertThatChoiceBodyHasNoContent(b, "stop")); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(22.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, INPUT), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())), + point -> + point + .hasSum(3.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, COMPLETION), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void allTheClientOptions() { + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .frequencyPenalty(0.0) + .maxTokens(100) + .presencePenalty(0.0) + .temperature(1.0) + .topP(1.0) + .stopOfStrings(Collections.singletonList("foo")) + .seed(100L) + .responseFormat(ResponseFormatText.builder().type(ResponseFormatText.Type.TEXT).build()) + .build(); + + long startTimeNanos = System.nanoTime(); + ChatCompletion result = openai.client.chat().completions().create(params); + long durationNanos = System.nanoTime() - startTimeNanos; + result.validate(); + + String content = "Southern Ocean."; + assertThat(result.choices().get(0).message().content()).hasValue(content); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_OPENAI_REQUEST_SEED, 100L) + .hasAttribute(GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT, "text") + .hasAttribute(GEN_AI_OPERATION_NAME, "chat") + .hasAttribute(GEN_AI_REQUEST_FREQUENCY_PENALTY, 0.0) + .hasAttribute(GEN_AI_REQUEST_MAX_TOKENS, 100L) + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_REQUEST_PRESENCE_PENALTY, 0.0) + .hasAttribute(GEN_AI_REQUEST_STOP_SEQUENCES, Collections.singletonList("foo")) + .hasAttribute(GEN_AI_REQUEST_TEMPERATURE, 1.0) + .hasAttribute(GEN_AI_REQUEST_TOP_P, 1.0) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsExactly("stop"); + }) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, 22L) + .hasAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, 3L) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + SpanContext spanCtx = spans.get(0).getSpanContext(); + assertThat(testing.logRecords()) + .hasSize(2) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.user.message")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", TEST_CHAT_INPUT)); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.choice")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("index", 0) + .entry("finish_reason", "stop") + .entry("message", ValAssert.map().entry("content", content))); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(22.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, INPUT), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())), + point -> + point + .hasSum(3.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, COMPLETION), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void multipleChoicesWithCaptureContent() { + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .n(2) + .build(); + + long startTimeNanos = System.nanoTime(); + ChatCompletion result = openai.client.chat().completions().create(params); + long durationNanos = System.nanoTime() - startTimeNanos; + result.validate(); + + String content = "Atlantic Ocean."; + assertThat(result.choices().get(0).message().content()).hasValue(content); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_OPERATION_NAME, "chat") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsExactly("stop", "stop"); + }) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, 22L) + .hasAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, 9L) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + SpanContext spanCtx = spans.get(0).getSpanContext(); + assertThat(testing.logRecords()) + .hasSize(3) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.user.message")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", TEST_CHAT_INPUT)); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.choice")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("index", 0) + .entry("finish_reason", "stop") + .entry("message", ValAssert.map().entry("content", content))); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.choice")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("index", 1) + .entry("finish_reason", "stop") + .entry( + "message", + ValAssert.map().entry("content", "South Atlantic Ocean."))); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(22.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, INPUT), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())), + point -> + point + .hasSum(9.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, COMPLETION), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + // TODO: JSON strings are currently asserted by value, so different spacing from different + // providers + // can cause errors. They need to be updated to JSON assertions. + @Test + void toolCalls() { + InstrumentationSettingsAccessor.setCaptureMessageContent(openai.client, false); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages( + Arrays.asList( + createSystemMessage( + "You are a helpful customer support assistant. Use the supplied tools to assist the user."), + createUserMessage("Hi, can you tell me the delivery date for my order?"), + createAssistantMessage( + "Hi there! I can help with that. Can you please provide your order ID?"), + createUserMessage("i think it is order_12345"))) + .model(TEST_CHAT_MODEL) + .addTool(buildGetDeliveryDateToolDefinition()) + .build(); + + long startTimeNanos = System.nanoTime(); + ChatCompletion response = openai.client.chat().completions().create(params); + long durationNanos = System.nanoTime() - startTimeNanos; + response.validate(); + + List toolCalls = + response.choices().get(0).message().toolCalls().get(); + + assertThat(toolCalls).hasSize(1); + assertThat(toolCalls.get(0)) + .satisfies( + call -> { + assertThat(call.function().name()).isEqualTo("get_delivery_date"); + assertThat(call.function().arguments()).isEqualTo("{\"order_id\":\"order_12345\"}"); + }); + + assertThat(testing.spans()) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute( + GEN_AI_RESPONSE_FINISH_REASONS, Collections.singletonList("tool_calls")) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, 140L) + .hasAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, 20L) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + assertThat(testing.logRecords()) + .hasSize(5) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.system.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()).satisfies(ChatTest::assertThatValueIsEmptyMap); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()).satisfies(ChatTest::assertThatValueIsEmptyMap); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.assistant.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()).satisfies(ChatTest::assertThatValueIsEmptyMap); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()).satisfies(ChatTest::assertThatValueIsEmptyMap); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies(b -> assertThatChoiceBodyHasNoContent(b, "tool_calls")); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(140.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, INPUT), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())), + point -> + point + .hasSum(20.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, COMPLETION), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void toolCallsWithCaptureMessageContent() { + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages( + Arrays.asList( + createSystemMessage( + "You are a helpful customer support assistant. Use the supplied tools to assist the user."), + createUserMessage("Hi, can you tell me the delivery date for my order?"), + createAssistantMessage( + "Hi there! I can help with that. Can you please provide your order ID?"), + createUserMessage("i think it is order_12345"))) + .model(TEST_CHAT_MODEL) + .addTool(buildGetDeliveryDateToolDefinition()) + .build(); + + long startTimeNanos = System.nanoTime(); + ChatCompletion response = openai.client.chat().completions().create(params); + long durationNanos = System.nanoTime() - startTimeNanos; + response.validate(); + + List toolCalls = + response.choices().get(0).message().toolCalls().get(); + + assertThat(toolCalls).hasSize(1); + assertThat(toolCalls.get(0)) + .satisfies( + call -> { + assertThat(call.function().name()).isEqualTo("get_delivery_date"); + assertThat(call.function().arguments()).isEqualTo("{\"order_id\":\"order_12345\"}"); + assertThat(call.id()).startsWith("call_"); + }); + + String toolCallId = toolCalls.get(0).id(); + + assertThat(testing.spans()) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute( + GEN_AI_RESPONSE_FINISH_REASONS, Collections.singletonList("tool_calls")) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, 140L) + .hasAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, 20L) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + assertThat(testing.logRecords()) + .hasSize(5) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.system.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry( + "content", + "You are a helpful customer support assistant. Use the supplied tools to assist the user.")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("content", "Hi, can you tell me the delivery date for my order?")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.assistant.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry( + "content", + "Hi there! I can help with that. Can you please provide your order ID?")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", "i think it is order_12345")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("finish_reason", "tool_calls") + .entry("index", 0) + .entry( + "message", + ValAssert.map() + .entry( + "tool_calls", + ValAssert.array() + .ignoreOrder() + .entry( + ValAssert.map() + .entry("id", toolCallId) + .entry("type", "function") + .entry( + "function", + ValAssert.map() + .entry("name", "get_delivery_date") + .entry( + "arguments", + "{\"order_id\":\"order_12345\"}")))))); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(140.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, INPUT), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())), + point -> + point + .hasSum(20.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, COMPLETION), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void connectionError() { + OpenAIClient client = + OpenAIOkHttpClient.builder().baseUrl("http://localhost:9999/v5").apiKey("testing").build(); + InstrumentationSettingsAccessor.setEmitEvents(client, true); + InstrumentationSettingsAccessor.setCaptureMessageContent(client, true); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .build(); + + Exception exception = null; + long startTimeNanos = System.nanoTime(); + try { + client.chat().completions().create(params); + } catch (Exception e) { + exception = e; + } + long durationNanos = System.nanoTime() - startTimeNanos; + + Exception finalException = exception; + assertThat(finalException).isNotNull().isInstanceOf(OpenAIIoException.class); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasException(finalException) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, 9999L); + }); + + assertThat(testing.logRecords()) + .hasSize(1) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", TEST_CHAT_INPUT)); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(ERROR_TYPE, finalException.getClass().getName()), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, 9999L))))); + } + + @Test + void connectionErrorStream() { + OpenAIClient client = + OpenAIOkHttpClient.builder() + .baseUrl("http://localhost:" + openai.getPort() + "/bad-url") + .apiKey("testing") + .build(); + InstrumentationSettingsAccessor.setEmitEvents(client, true); + InstrumentationSettingsAccessor.setCaptureMessageContent(client, true); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .build(); + + Exception exception = null; + long startTimeNanos = System.nanoTime(); + try { + client.chat().completions().createStreaming(params); + } catch (Exception e) { + exception = e; + } + long durationNanos = System.nanoTime() - startTimeNanos; + Exception finalException = exception; + assertThat(finalException).isNotNull().isInstanceOf(NotFoundException.class); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasException(finalException) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + assertThat(testing.logRecords()) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", TEST_CHAT_INPUT)); + }) + .hasSize(1); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(ERROR_TYPE, finalException.getClass().getName()), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void captureMessageContent() { + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .build(); + + long startTimeNanos = System.nanoTime(); + ChatCompletion result = openai.client.chat().completions().create(params); + long durationNanos = System.nanoTime() - startTimeNanos; + result.validate(); + + String content = "Southern Ocean"; + assertThat(result.choices().get(0).message().content()).hasValue(content); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_OPERATION_NAME, "chat") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsExactly("stop"); + }) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, 22L) + .hasAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, 3L) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + SpanContext spanCtx = spans.get(0).getSpanContext(); + assertThat(testing.logRecords()) + .hasSize(2) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.user.message")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", TEST_CHAT_INPUT)); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.choice")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("index", 0) + .entry("finish_reason", "stop") + .entry("message", ValAssert.map().entry("content", content))); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(22.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, INPUT), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())), + point -> + point + .hasSum(3.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, COMPLETION), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void stream() throws Exception { + InstrumentationSettingsAccessor.setCaptureMessageContent(openai.client, false); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .build(); + + long startTimeNanos = System.nanoTime(); + List chunks; + try (StreamResponse result = + openai.client.chat().completions().createStreaming(params)) { + chunks = result.stream().collect(Collectors.toList()); + } + long durationNanos = System.nanoTime() - startTimeNanos; + + String fullMessage = + chunks.stream() + .map(cc -> cc.choices().get(0).delta().content()) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.joining()); + + String content = "South Atlantic Ocean."; + assertThat(fullMessage).isEqualTo(content); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsAnyOf("stop"); + }); + }); + SpanContext spanCtx = spans.get(0).getSpanContext(); + + assertThat(testing.logRecords()) + .hasSize(2) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()).satisfies(ChatTest::assertThatValueIsEmptyMap); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(b -> assertThatChoiceBodyHasNoContent(b, "stop")); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void streamWithIncludeUsage() throws Exception { + InstrumentationSettingsAccessor.setCaptureMessageContent(openai.client, false); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .streamOptions(ChatCompletionStreamOptions.builder().includeUsage(true).build()) + .build(); + + long startTimeNanos = System.nanoTime(); + List chunks; + try (StreamResponse result = + openai.client.chat().completions().createStreaming(params)) { + chunks = result.stream().collect(Collectors.toList()); + } + long durationNanos = System.nanoTime() - startTimeNanos; + + String fullMessage = + chunks.stream() + .filter(cc -> !cc.choices().isEmpty()) + .map(cc -> cc.choices().get(0).delta().content()) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.joining()); + + String content = "South Atlantic Ocean."; + assertThat(fullMessage).isEqualTo(content); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_OPERATION_NAME, "chat") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsExactly("stop"); + }) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, 22L) + .hasAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, 4L) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + SpanContext spanCtx = spans.get(0).getSpanContext(); + assertThat(testing.logRecords()) + .hasSize(2) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()).satisfies(ChatTest::assertThatValueIsEmptyMap); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(b -> assertThatChoiceBodyHasNoContent(b, "stop")); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(22.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, INPUT), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())), + point -> + point + .hasSum(4.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, COMPLETION), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void streamAllTheClientOptions() throws Exception { + InstrumentationSettingsAccessor.setCaptureMessageContent(openai.client, false); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .frequencyPenalty(0.0) + .maxTokens(100) + .presencePenalty(0.0) + .temperature(1.0) + .topP(1.0) + .stopOfStrings(Collections.singletonList("foo")) + .seed(100L) + .responseFormat(ResponseFormatText.builder().type(ResponseFormatText.Type.TEXT).build()) + .build(); + + long startTimeNanos = System.nanoTime(); + List chunks; + try (StreamResponse result = + openai.client.chat().completions().createStreaming(params)) { + chunks = result.stream().collect(Collectors.toList()); + } + long durationNanos = System.nanoTime() - startTimeNanos; + + String fullMessage = + chunks.stream() + .map(cc -> cc.choices().get(0).delta().content()) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.joining()); + + String content = "Southern Ocean."; + assertThat(fullMessage).isEqualTo(content); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_OPENAI_REQUEST_SEED, 100L) + .hasAttribute(GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT, "text") + .hasAttribute(GEN_AI_OPERATION_NAME, "chat") + .hasAttribute(GEN_AI_REQUEST_FREQUENCY_PENALTY, 0.0) + .hasAttribute(GEN_AI_REQUEST_MAX_TOKENS, 100L) + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_REQUEST_PRESENCE_PENALTY, 0.0) + .hasAttribute(GEN_AI_REQUEST_STOP_SEQUENCES, Collections.singletonList("foo")) + .hasAttribute(GEN_AI_REQUEST_TEMPERATURE, 1.0) + .hasAttribute(GEN_AI_REQUEST_TOP_P, 1.0) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsExactly("stop"); + }) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + SpanContext spanCtx = spans.get(0).getSpanContext(); + assertThat(testing.logRecords()) + .hasSize(2) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()).satisfies(ChatTest::assertThatValueIsEmptyMap); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(b -> assertThatChoiceBodyHasNoContent(b, "stop")); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void streamWithCaptureMessageContent() throws Exception { + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .build(); + + long startTimeNanos = System.nanoTime(); + List chunks; + try (StreamResponse result = + openai.client.chat().completions().createStreaming(params)) { + chunks = result.stream().collect(Collectors.toList()); + } + long durationNanos = System.nanoTime() - startTimeNanos; + + String fullMessage = + chunks.stream() + .map(cc -> cc.choices().get(0).delta().content()) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.joining()); + + String content = "South Atlantic Ocean."; + assertThat(fullMessage).isEqualTo(content); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsAnyOf("stop"); + }); + }); + SpanContext spanCtx = spans.get(0).getSpanContext(); + assertThat(testing.logRecords()) + .hasSize(2) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", TEST_CHAT_INPUT)); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("finish_reason", "stop") + .entry("index", 0) + .entry("message", ValAssert.map().entry("content", content))); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void streamToolsAndCaptureMessageContent() throws Exception { + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages( + Arrays.asList( + createSystemMessage( + "You are a helpful customer support assistant. Use the supplied tools to assist the user."), + createUserMessage("Hi, can you tell me the delivery date for my order?"), + createAssistantMessage( + "Hi there! I can help with that. Can you please provide your order ID?"), + createUserMessage("i think it is order_12345"))) + .model(TEST_CHAT_MODEL) + .addTool(buildGetDeliveryDateToolDefinition()) + .build(); + + long startTimeNanos = System.nanoTime(); + List chunks; + try (StreamResponse result = + openai.client.chat().completions().createStreaming(params)) { + chunks = result.stream().collect(Collectors.toList()); + } + long durationNanos = System.nanoTime() - startTimeNanos; + + String fullMessage = + chunks.stream() + .map(cc -> cc.choices().get(0).delta().content()) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.joining()); + assertThat(fullMessage).isEmpty(); + + String toolCallId = + chunks.stream() + .map(chunk -> chunk.choices().get(0).delta().toolCalls()) + .filter(Optional::isPresent) + .map(Optional::get) + .map(tool -> tool.get(0).id()) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst() + .get(); + + assertThat(testing.spans()) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute( + GEN_AI_RESPONSE_FINISH_REASONS, Collections.singletonList("tool_calls")) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + assertThat(testing.logRecords()) + .hasSize(5) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.system.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry( + "content", + "You are a helpful customer support assistant. Use the supplied tools to assist the user.")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("content", "Hi, can you tell me the delivery date for my order?")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.assistant.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry( + "content", + "Hi there! I can help with that. Can you please provide your order ID?")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", "i think it is order_12345")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("finish_reason", "tool_calls") + .entry("index", 0) + .entry( + "message", + ValAssert.map() + .entry( + "tool_calls", + ValAssert.array() + .ignoreOrder() + .entry( + ValAssert.map() + .entry("id", toolCallId) + .entry("type", "function") + .entry( + "function", + ValAssert.map() + .entry("name", "get_delivery_date") + .entry( + "arguments", + "{\"order_id\":\"order_12345\"}")))))); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + // TODO: recheck for OpenAi client if this test is now possible + /* + + @ParameterizedTest + @ValueSource(booleans = { true, false}) + @Disabled("Azure SDK does not allow implementing correctly - https://github.com/Azure/azure-sdk-for-java/issues/43611") + void streamParallelToolsAndCaptureMessageContent(boolean useResponseApi) { + ChatCompletionsFunctionToolDefinitionFunction functionDefinition = new ChatCompletionsFunctionToolDefinitionFunction("get_weather"); + GetWeatherFunctionParameterDefinition + parameters = new GetWeatherFunctionParameterDefinition(); + functionDefinition.setParameters(BinaryData.fromObject(parameters)); + + List chatMessages = new ArrayList<>(); + chatMessages.add(new ChatRequestSystemMessage("You are a helpful assistant providing weather updates.")); + chatMessages.add(new ChatRequestUserMessage("What is the weather in New York City and London?")); + + ChatCompletionsOptions options = new ChatCompletionsOptions(chatMessages) + .setTools(List.of(new ChatCompletionsFunctionToolDefinition(functionDefinition))); + + + long startTimeNanos = System.nanoTime(); + IterableStream chatCompletionsStream; + if (useResponseApi) { + chatCompletionsStream = openai.client.getChatCompletionsStreamWithResponse(TEST_CHAT_MODEL, options, new RequestOptions()) + .getValue(); + } else { + chatCompletionsStream = openai.client.getChatCompletionsStream(TEST_CHAT_MODEL, options); + } + List completions = chatCompletionsStream.stream().toList(); + long durationNanos = System.nanoTime() - startTimeNanos; + + String fullMessage = completions.stream() + .map(cc -> cc.getChoices().get(0).getDelta()) + .filter(Objects::nonNull) + .map(ChatResponseMessage::getContent) + .filter(Objects::nonNull) + .collect(Collectors.joining()); + assertThat(fullMessage).isEmpty(); + + + assertThat(testing.spans()) + .hasSize(1) + .first() + .satisfies(span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttribute(GEN_AI_RESPONSE_ID, "chatcmpl-AiWm43aBO7oeXWZ3bOGNh48zGSfDN") + .hasAttribute(GEN_AI_RESPONSE_FINISH_REASONS, List.of("tool_calls")) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + assertThat(testing.logRecords()) + .hasSize(3) + .anySatisfy(log -> { + assertThat(log) + .hasAttributesSatisfying(attr -> assertThat(attr) + .containsEntry("event.name", "gen_ai.system.message") + .containsEntry(GEN_AI_SYSTEM, "openai") + ); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map() + .entry("content", "You are a helpful assistant providing weather updates.") + ); + }) + .anySatisfy(log -> { + assertThat(log) + .hasAttributesSatisfying(attr -> assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai") + ); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map() + .entry("content", "What is the weather in New York City and London?") + ); + }) + .anySatisfy(log -> { + assertThat(log) + .hasAttributesSatisfying(attr -> assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai") + ); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map() + .entry("finish_reason", "tool_calls") + .entry("index", 0) + .entry("message", ValAssert.map() + .entry("tool_calls", ValAssert.array().ignoreOrder() + .entry(ValAssert.map() + .entry("id", "call_HtKN6juSPlIW7Jn6wGu9Fl98") + .entry("type", "function") + .entry("function", ValAssert.map() + .entry("name", "get_weather") + .entry("arguments", "{\"location\": \"New York City\"}") + )) + .entry(ValAssert.map() + .entry("id", "call_YJvXI6DrRWpyOXEGQmX8sX30") + .entry("type", "function") + .entry("function", ValAssert.map() + .entry("name", "get_weather") + .entry("arguments", "{\"location\": \"London\"}") + )) + ) + ) + ); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> metric.hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()) + ) + ) + )); + } + */ + + @Test + void toolsWithFollowupAndCaptureContent() { + + List chatMessages = new ArrayList<>(); + chatMessages.add(createSystemMessage("You are a helpful assistant providing weather updates.")); + chatMessages.add(createUserMessage("What is the weather in New York City and London?")); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(chatMessages) + .model(TEST_CHAT_MODEL) + .addTool(buildGetWeatherToolDefinition()) + .build(); + + long startTimeNanosFirst = System.nanoTime(); + ChatCompletion response = openai.client.chat().completions().create(params); + long durationNanosFirst = System.nanoTime() - startTimeNanosFirst; + response.validate(); + + assertThat(response.choices().get(0).message().content()).isEmpty(); + + List toolCalls = + response.choices().get(0).message().toolCalls().get(); + assertThat(toolCalls).hasSize(2); + String newYorkCallId = + toolCalls.stream() + .filter(call -> call.function().arguments().contains("New York")) + .map(ChatCompletionMessageToolCall::id) + .findFirst() + .get(); + String londonCallId = + toolCalls.stream() + .filter(call -> call.function().arguments().contains("London")) + .map(ChatCompletionMessageToolCall::id) + .findFirst() + .get(); + + assertThat(newYorkCallId).startsWith("call_"); + assertThat(londonCallId).startsWith("call_"); + + assertThat(testing.spans()) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute( + GEN_AI_RESPONSE_FINISH_REASONS, Collections.singletonList("tool_calls")) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + assertThat(testing.logRecords()) + .hasSize(3) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.system.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry( + "content", "You are a helpful assistant providing weather updates.")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("content", "What is the weather in New York City and London?")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("finish_reason", "tool_calls") + .entry("index", 0) + .entry( + "message", + ValAssert.map() + .entry( + "tool_calls", + ValAssert.array() + .ignoreOrder() + .entry( + ValAssert.map() + .entry("id", newYorkCallId) + .entry("type", "function") + .entry( + "function", + ValAssert.map() + .entry("name", "get_weather") + .entry( + "arguments", + "{\"location\": \"New York City\"}"))) + .entry( + ValAssert.map() + .entry("id", londonCallId) + .entry("type", "function") + .entry( + "function", + ValAssert.map() + .entry("name", "get_weather") + .entry( + "arguments", + "{\"location\": \"London\"}")))))); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanosFirst)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(67.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, INPUT), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())), + point -> + point + .hasSum(47.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, COMPLETION), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + testing.clearData(); + + ChatCompletionMessageParam assistantMessage = + ChatCompletionMessageParam.ofChatCompletionAssistantMessageParam( + ChatCompletionAssistantMessageParam.builder() + .role(ChatCompletionAssistantMessageParam.Role.ASSISTANT) + .content(ChatCompletionAssistantMessageParam.Content.ofTextContent("")) + .toolCalls(toolCalls) + .build()); + + chatMessages.add(assistantMessage); + chatMessages.add(createToolMessage("25 degrees and sunny", newYorkCallId)); + chatMessages.add(createToolMessage("15 degrees and raining", londonCallId)); + + long startTimeNanosSecond = System.nanoTime(); + ChatCompletion fullCompletion = + openai + .client + .chat() + .completions() + .create( + ChatCompletionCreateParams.builder() + .messages(chatMessages) + .model(TEST_CHAT_MODEL) + .build()); + long durationNanosSecond = System.nanoTime() - startTimeNanosSecond; + fullCompletion.validate(); + + ChatCompletion.Choice finalChoice = fullCompletion.choices().get(0); + String finalAnswer = finalChoice.message().content().get(); + + assertThat(testing.spans()) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(GEN_AI_RESPONSE_FINISH_REASONS, Collections.singletonList("stop")) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + assertThat(testing.logRecords()) + .hasSize(6) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.system.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry( + "content", "You are a helpful assistant providing weather updates.")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("content", "What is the weather in New York City and London?")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.assistant.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry( + "tool_calls", + ValAssert.array() + .entry( + ValAssert.map() + .entry("id", newYorkCallId) + .entry("type", "function") + .entry( + "function", + ValAssert.map() + .entry("name", "get_weather") + .entry( + "arguments", + "{\"location\": \"New York City\"}"))) + .entry( + ValAssert.map() + .entry("id", londonCallId) + .entry("type", "function") + .entry( + "function", + ValAssert.map() + .entry("name", "get_weather") + .entry( + "arguments", + "{\"location\": \"London\"}"))))); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.tool.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("id", newYorkCallId) + .entry("content", "25 degrees and sunny")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.tool.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("id", londonCallId) + .entry("content", "15 degrees and raining")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("finish_reason", finalChoice.finishReason().toString()) + .entry("index", 0) + .entry("message", ValAssert.map().entry("content", finalAnswer))); + }); + + testing.waitAndAssertMetrics( + Constants.INSTRUMENTATION_NAME, + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanosSecond)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(99.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, INPUT), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())), + point -> + point + .hasSum(27.0) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL), + equalTo(GEN_AI_TOKEN_TYPE, COMPLETION), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort()))))); + } + + @Test + void disableEvents() { + // Override the enablement from setup() + InstrumentationSettingsAccessor.setEmitEvents(openai.client, false); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(Collections.singletonList(createUserMessage(TEST_CHAT_INPUT))) + .model(TEST_CHAT_MODEL) + .build(); + + ChatCompletion result = openai.client.chat().completions().create(params); + result.validate(); + + String content = "Southern Ocean"; + assertThat(result.choices().get(0).message().content()).hasValue(content); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_OPERATION_NAME, "chat") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttributesSatisfying( + att -> assertThat(att.get(GEN_AI_RESPONSE_ID)).startsWith("chatcmpl-")) + .hasAttribute(GEN_AI_RESPONSE_MODEL, TEST_CHAT_RESPONSE_MODEL) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsExactly("stop"); + }) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, 22L) + .hasAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, 3L) + .hasAttribute(SERVER_ADDRESS, "localhost") + .hasAttribute(SERVER_PORT, (long) openai.getPort()); + }); + + assertThat(testing.logRecords()).isEmpty(); + } + + private static ChatCompletionTool buildGetWeatherToolDefinition() { + Map location = new HashMap<>(); + location.put("type", JsonValue.from("string")); + location.put("description", JsonValue.from("The location to get the current temperature for")); + + Map properties = new HashMap<>(); + properties.put("location", JsonObject.of(location)); + + return ChatCompletionTool.builder() + .type(ChatCompletionTool.Type.FUNCTION) + .function( + FunctionDefinition.builder() + .name("get_weather") + .parameters( + FunctionParameters.builder() + .putAdditionalProperty("type", JsonValue.from("object")) + .putAdditionalProperty( + "required", JsonValue.from(Collections.singletonList("location"))) + .putAdditionalProperty("additionalProperties", JsonValue.from(false)) + .putAdditionalProperty("properties", JsonObject.of(properties)) + .build()) + .build()) + .build(); + } + + static ChatCompletionTool buildGetDeliveryDateToolDefinition() { + Map orderId = new HashMap<>(); + orderId.put("type", JsonValue.from("string")); + orderId.put("description", JsonValue.from("The customer's order ID.")); + + Map properties = new HashMap<>(); + properties.put("order_id", JsonObject.of(orderId)); + + return ChatCompletionTool.builder() + .type(ChatCompletionTool.Type.FUNCTION) + .function( + FunctionDefinition.builder() + .name("get_delivery_date") + .description( + "Get the delivery date for a customer's order. " + + "Call this whenever you need to know the delivery date, for " + + "example when a customer asks 'Where is my package'") + .parameters( + FunctionParameters.builder() + .putAdditionalProperty("type", JsonValue.from("object")) + .putAdditionalProperty( + "required", JsonValue.from(Collections.singletonList("order_id"))) + .putAdditionalProperty("additionalProperties", JsonValue.from(false)) + .putAdditionalProperty("properties", JsonObject.of(properties)) + .build()) + .build()) + .build(); + } + + private static ChatCompletionMessageParam createAssistantMessage(String content) { + return ChatCompletionMessageParam.ofChatCompletionAssistantMessageParam( + ChatCompletionAssistantMessageParam.builder() + .role(ChatCompletionAssistantMessageParam.Role.ASSISTANT) + .content(ChatCompletionAssistantMessageParam.Content.ofTextContent(content)) + .build()); + } + + private static ChatCompletionMessageParam createUserMessage(String content) { + return ChatCompletionMessageParam.ofChatCompletionUserMessageParam( + ChatCompletionUserMessageParam.builder() + .role(ChatCompletionUserMessageParam.Role.USER) + .content(ChatCompletionUserMessageParam.Content.ofTextContent(content)) + .build()); + } + + private static ChatCompletionMessageParam createSystemMessage(String content) { + return ChatCompletionMessageParam.ofChatCompletionSystemMessageParam( + ChatCompletionSystemMessageParam.builder() + .role(ChatCompletionSystemMessageParam.Role.SYSTEM) + .content(ChatCompletionSystemMessageParam.Content.ofTextContent(content)) + .build()); + } + + private static ChatCompletionMessageParam createToolMessage(String response, String id) { + return ChatCompletionMessageParam.ofChatCompletionToolMessageParam( + ChatCompletionToolMessageParam.builder() + .role(ChatCompletionToolMessageParam.Role.TOOL) + .toolCallId(id) + .content(ChatCompletionToolMessageParam.Content.ofTextContent(response)) + .build()); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/EmbeddingsTest.java b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/EmbeddingsTest.java new file mode 100644 index 00000000..856f81b8 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/EmbeddingsTest.java @@ -0,0 +1,118 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; +import static io.opentelemetry.semconv.ServerAttributes.SERVER_PORT; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_OPERATION_NAME; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_ENCODING_FORMATS; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_MODEL; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_RESPONSE_MODEL; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_SYSTEM; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_USAGE_INPUT_TOKENS; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.openai.errors.NotFoundException; +import com.openai.models.CreateEmbeddingResponse; +import com.openai.models.EmbeddingCreateParams; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.instrumentation.testing.junit.AgentInstrumentationExtension; +import java.util.Collections; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class EmbeddingsTest { + private static final String MODEL = + System.getenv().getOrDefault("OPENAI_MODEL", "text-embedding-3-small"); + + @RegisterExtension + static final AgentInstrumentationExtension testing = AgentInstrumentationExtension.create(); + + @RegisterExtension + static final OpenAIRecordingExtension openai = new OpenAIRecordingExtension("EmbeddingsTest"); + + @Test + void basic() { + String text = "South Atlantic Ocean."; + + EmbeddingCreateParams request = + EmbeddingCreateParams.builder() + .model(MODEL) + .encodingFormat(EmbeddingCreateParams.EncodingFormat.BASE64) + .inputOfArrayOfStrings(Collections.singletonList(text)) + .build(); + CreateEmbeddingResponse response = openai.client.embeddings().create(request); + + assertThat(response.data()).hasSize(1); + + testing.waitAndAssertTracesWithoutScopeVersionVerification( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName("embeddings " + MODEL) + .hasKind(SpanKind.CLIENT) + .hasStatusSatisfying(status -> status.hasCode(StatusCode.UNSET)) + .hasAttributesSatisfying( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "embeddings"), + equalTo(GEN_AI_REQUEST_MODEL, MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, "text-embedding-3-small"), + equalTo( + GEN_AI_REQUEST_ENCODING_FORMATS, + Collections.singletonList("base64")), + equalTo(GEN_AI_USAGE_INPUT_TOKENS, 4), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))); + } + + @Test + void invalidModel() { + String text = "South Atlantic Ocean."; + + EmbeddingCreateParams request = + EmbeddingCreateParams.builder() + .model("not-a-model") + .encodingFormat(EmbeddingCreateParams.EncodingFormat.BASE64) + .inputOfArrayOfStrings(Collections.singletonList(text)) + .build(); + + assertThatThrownBy(() -> openai.client.embeddings().create(request)) + .isInstanceOf(NotFoundException.class); + + testing.waitAndAssertTracesWithoutScopeVersionVerification( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName("embeddings not-a-model") + .hasKind(SpanKind.CLIENT) + .hasStatusSatisfying(status -> status.hasCode(StatusCode.ERROR)) + .hasAttributesSatisfying( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "embeddings"), + equalTo(GEN_AI_REQUEST_MODEL, "not-a-model"), + equalTo( + GEN_AI_REQUEST_ENCODING_FORMATS, + Collections.singletonList("base64")), + equalTo(SERVER_ADDRESS, "localhost"), + equalTo(SERVER_PORT, (long) openai.getPort())))); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/LiveAPIChatIntegrationTest.java b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/LiveAPIChatIntegrationTest.java new file mode 100644 index 00000000..fb45cd34 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/LiveAPIChatIntegrationTest.java @@ -0,0 +1,616 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.semconv.ServerAttributes.SERVER_ADDRESS; +import static io.opentelemetry.semconv.ServerAttributes.SERVER_PORT; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_OPERATION_NAME; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_REQUEST_MODEL; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_RESPONSE_FINISH_REASONS; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_RESPONSE_ID; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_RESPONSE_MODEL; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_SYSTEM; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_TOKEN_TYPE; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_USAGE_INPUT_TOKENS; +import static io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GEN_AI_USAGE_OUTPUT_TOKENS; + +import co.elastic.otel.openai.wrappers.InstrumentationSettingsAccessor; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.core.http.StreamResponse; +import com.openai.models.ChatCompletion; +import com.openai.models.ChatCompletionAssistantMessageParam; +import com.openai.models.ChatCompletionChunk; +import com.openai.models.ChatCompletionCreateParams; +import com.openai.models.ChatCompletionMessageParam; +import com.openai.models.ChatCompletionMessageToolCall; +import com.openai.models.ChatCompletionStreamOptions; +import com.openai.models.ChatCompletionSystemMessageParam; +import com.openai.models.ChatCompletionToolMessageParam; +import com.openai.models.ChatCompletionUserMessageParam; +import com.openai.models.CompletionUsage; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.instrumentation.testing.junit.AgentInstrumentationExtension; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes.GenAiTokenTypeIncubatingValues; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * This test runs against the real OpenAI-API. It therefore requires an API-key to be setup. This + * test is currently not executed on CI. + */ +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +class LiveAPIChatIntegrationTest { + private static final String TEST_CHAT_MODEL = + System.getenv().getOrDefault("TEST_CHAT_MODEL", "gpt-4o-mini"); + private static final String TEST_CHAT_INPUT = + "Answer in up to 3 words: Which ocean contains Bouvet Island?"; + + @RegisterExtension + static final AgentInstrumentationExtension testing = AgentInstrumentationExtension.create(); + + private OpenAIClient client; + private String clientHost; + private long clientPort; + + @BeforeEach + void setup() throws Exception { + + OpenAIOkHttpClient.Builder builder = + OpenAIOkHttpClient.builder().apiKey(System.getenv().get("OPENAI_API_KEY")); + String baseUrl = System.getenv().get("OPENAI_BASE_URL"); + if (baseUrl != null) { + URL url = new URL(baseUrl); + clientHost = url.getHost(); + clientPort = url.getPort(); + if (clientPort == -1) { + clientPort = url.getDefaultPort(); + } + builder.baseUrl(baseUrl); + } else { + clientHost = "api.openai.com"; + clientPort = 443; + } + client = builder.build(); + + InstrumentationSettingsAccessor.setCaptureMessageContent(client, true); + InstrumentationSettingsAccessor.setEmitEvents(client, true); + } + + @Test + void toolCallsWithCaptureMessageContent() { + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages( + Arrays.asList( + createSystemMessage( + "You are a helpful customer support assistant. Use the supplied tools to assist the user."), + createUserMessage("Hi, can you tell me the delivery date for my order?"), + createAssistantMessage( + "Hi there! I can help with that. Can you please provide your order ID?"), + createUserMessage("i think it is order_12345"))) + .model(TEST_CHAT_MODEL) + .addTool(ChatTest.buildGetDeliveryDateToolDefinition()) + .build(); + + long startTimeNanos = System.nanoTime(); + ChatCompletion response = client.chat().completions().create(params); + long durationNanos = System.nanoTime() - startTimeNanos; + + List toolCalls = + response.choices().get(0).message().toolCalls().get(); + + assertThat(toolCalls).hasSize(1); + assertThat(toolCalls.get(0)) + .satisfies( + call -> { + assertThat(call.function().name()).isEqualTo("get_delivery_date"); + assertThat(call.function().arguments()).isEqualTo("{\"order_id\":\"order_12345\"}"); + }); + + assertThat(testing.spans()) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, response.model()) + .hasAttribute(GEN_AI_RESPONSE_ID, response.id()) + .hasAttribute( + GEN_AI_RESPONSE_FINISH_REASONS, Collections.singletonList("tool_calls")) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, response.usage().get().promptTokens()) + .hasAttribute( + GEN_AI_USAGE_OUTPUT_TOKENS, response.usage().get().completionTokens()) + .hasAttribute(SERVER_ADDRESS, clientHost) + .hasAttribute(SERVER_PORT, clientPort); + }); + + assertThat(testing.logRecords()) + .hasSize(5) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.system.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry( + "content", + "You are a helpful customer support assistant. Use the supplied tools to assist the user.")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("content", "Hi, can you tell me the delivery date for my order?")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.assistant.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry( + "content", + "Hi there! I can help with that. Can you please provide your order ID?")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", "i think it is order_12345")); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("finish_reason", "tool_calls") + .entry("index", 0) + .entry( + "message", + ValAssert.map() + .entry( + "tool_calls", + ValAssert.array() + .ignoreOrder() + .entry( + ValAssert.map() + .entry("id", toolCalls.get(0).id()) + .entry("type", "function") + .entry( + "function", + ValAssert.map() + .entry("name", "get_delivery_date") + .entry( + "arguments", + "{\"order_id\":\"order_12345\"}")))))); + }); + + testing.waitAndAssertMetrics( + "openai-client", + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, response.model()), + equalTo(SERVER_ADDRESS, clientHost), + equalTo(SERVER_PORT, clientPort)))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(response.usage().get().promptTokens()) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, response.model()), + equalTo( + GEN_AI_TOKEN_TYPE, + GenAiTokenTypeIncubatingValues.INPUT), + equalTo(SERVER_ADDRESS, clientHost), + equalTo(SERVER_PORT, clientPort)), + point -> + point + .hasSum(response.usage().get().completionTokens()) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, response.model()), + equalTo( + GEN_AI_TOKEN_TYPE, + GenAiTokenTypeIncubatingValues.COMPLETION), + equalTo(SERVER_ADDRESS, clientHost), + equalTo(SERVER_PORT, clientPort))))); + } + + @Test + void captureMessageContent() { + List chatMessages = new ArrayList<>(); + chatMessages.add(createUserMessage(TEST_CHAT_INPUT)); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder().messages(chatMessages).model(TEST_CHAT_MODEL).build(); + + long startTimeNanos = System.nanoTime(); + ChatCompletion response = client.chat().completions().create(params); + long durationNanos = System.nanoTime() - startTimeNanos; + + String content = response.choices().get(0).message().content().get(); + assertThat(content).isNotEmpty(); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_OPERATION_NAME, "chat") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_RESPONSE_ID, response.id()) + .hasAttribute(GEN_AI_RESPONSE_MODEL, response.model()) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsExactly("stop"); + }) + .hasAttribute( + GEN_AI_USAGE_INPUT_TOKENS, (long) response.usage().get().promptTokens()) + .hasAttribute( + GEN_AI_USAGE_OUTPUT_TOKENS, (long) response.usage().get().completionTokens()) + .hasAttribute(SERVER_ADDRESS, clientHost) + .hasAttribute(SERVER_PORT, clientPort); + }); + + SpanContext spanCtx = spans.get(0).getSpanContext(); + assertThat(testing.logRecords()) + .hasSize(2) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.user.message")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", TEST_CHAT_INPUT)); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry(GEN_AI_SYSTEM, "openai") + .containsEntry("event.name", "gen_ai.choice")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("index", 0) + .entry("finish_reason", "stop") + .entry("message", ValAssert.map().entry("content", content))); + }); + + testing.waitAndAssertMetrics( + "openai-client", + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, response.model()), + equalTo(SERVER_ADDRESS, clientHost), + equalTo(SERVER_PORT, clientPort)))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(response.usage().get().promptTokens()) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, response.model()), + equalTo( + GEN_AI_TOKEN_TYPE, + GenAiTokenTypeIncubatingValues.INPUT), + equalTo(SERVER_ADDRESS, clientHost), + equalTo(SERVER_PORT, clientPort)), + point -> + point + .hasSum(response.usage().get().completionTokens()) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, response.model()), + equalTo( + GEN_AI_TOKEN_TYPE, + GenAiTokenTypeIncubatingValues.COMPLETION), + equalTo(SERVER_ADDRESS, clientHost), + equalTo(SERVER_PORT, clientPort))))); + } + + @Test + void streamWithCaptureMessageContent() throws Exception { + List chatMessages = new ArrayList<>(); + chatMessages.add(createUserMessage(TEST_CHAT_INPUT)); + + ChatCompletionCreateParams params = + ChatCompletionCreateParams.builder() + .messages(chatMessages) + .model(TEST_CHAT_MODEL) + .streamOptions(ChatCompletionStreamOptions.builder().includeUsage(true).build()) + .build(); + + List completions; + long startTimeNanos = System.nanoTime(); + try (StreamResponse response = + client.chat().completions().createStreaming(params)) { + completions = response.stream().collect(Collectors.toList()); + } + long durationNanos = System.nanoTime() - startTimeNanos; + + String content = + completions.stream() + .map( + cc -> + cc.choices().stream() + .findFirst() + .map(ChatCompletionChunk.Choice::delta) + .orElse(null)) + .filter(Objects::nonNull) + .map(ChatCompletionChunk.Choice.Delta::content) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.joining()); + String responseModel = completions.stream().map(ChatCompletionChunk::model).findFirst().get(); + String responseId = completions.stream().map(ChatCompletionChunk::id).findFirst().get(); + CompletionUsage usage = + completions.stream() + .map(ChatCompletionChunk::usage) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst() + .get(); + + assertThat(content).isNotEmpty(); + + List spans = testing.spans(); + assertThat(spans) + .hasSize(1) + .first() + .satisfies( + span -> { + assertThat(span) + .hasAttribute(GEN_AI_SYSTEM, "openai") + .hasAttribute(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL) + .hasAttribute(GEN_AI_RESPONSE_MODEL, responseModel) + .hasAttribute(GEN_AI_RESPONSE_ID, responseId) + .hasAttribute(SERVER_ADDRESS, clientHost) + .hasAttribute(SERVER_PORT, clientPort) + .hasAttribute(GEN_AI_USAGE_INPUT_TOKENS, usage.promptTokens()) + .hasAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, usage.completionTokens()) + .hasAttributesSatisfying( + attribs -> { + assertThat(attribs.get(GEN_AI_RESPONSE_FINISH_REASONS)) + .containsAnyOf("stop"); + }); + }); + SpanContext spanCtx = spans.get(0).getSpanContext(); + assertThat(testing.logRecords()) + .hasSize(2) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.user.message") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies(ValAssert.map().entry("content", TEST_CHAT_INPUT)); + }) + .anySatisfy( + log -> { + assertThat(log) + .hasAttributesSatisfying( + attr -> + assertThat(attr) + .containsEntry("event.name", "gen_ai.choice") + .containsEntry(GEN_AI_SYSTEM, "openai")) + .hasSpanContext(spanCtx); + assertThat(log.getBodyValue()) + .satisfies( + ValAssert.map() + .entry("finish_reason", "stop") + .entry("index", 0) + .entry("message", ValAssert.map().entry("content", content))); + }); + + testing.waitAndAssertMetrics( + "openai-client", + metric -> + metric + .hasName("gen_ai.client.operation.duration") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .satisfies(assertThatDurationIsLessThan(durationNanos)) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, responseModel), + equalTo(SERVER_ADDRESS, clientHost), + equalTo(SERVER_PORT, clientPort)))), + metric -> + metric + .hasName("gen_ai.client.token.usage") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasSum(usage.promptTokens()) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, responseModel), + equalTo( + GEN_AI_TOKEN_TYPE, + GenAiTokenTypeIncubatingValues.INPUT), + equalTo(SERVER_ADDRESS, clientHost), + equalTo(SERVER_PORT, clientPort)), + point -> + point + .hasSum(usage.completionTokens()) + .hasAttributesSatisfyingExactly( + equalTo(GEN_AI_SYSTEM, "openai"), + equalTo(GEN_AI_OPERATION_NAME, "chat"), + equalTo(GEN_AI_REQUEST_MODEL, TEST_CHAT_MODEL), + equalTo(GEN_AI_RESPONSE_MODEL, responseModel), + equalTo( + GEN_AI_TOKEN_TYPE, + GenAiTokenTypeIncubatingValues.COMPLETION), + equalTo(SERVER_ADDRESS, clientHost), + equalTo(SERVER_PORT, clientPort))))); + } + + private static ChatCompletionMessageParam createAssistantMessage(String content) { + return ChatCompletionMessageParam.ofChatCompletionAssistantMessageParam( + ChatCompletionAssistantMessageParam.builder() + .role(ChatCompletionAssistantMessageParam.Role.ASSISTANT) + .content(ChatCompletionAssistantMessageParam.Content.ofTextContent(content)) + .build()); + } + + private static ChatCompletionMessageParam createUserMessage(String content) { + return ChatCompletionMessageParam.ofChatCompletionUserMessageParam( + ChatCompletionUserMessageParam.builder() + .role(ChatCompletionUserMessageParam.Role.USER) + .content(ChatCompletionUserMessageParam.Content.ofTextContent(content)) + .build()); + } + + private static ChatCompletionMessageParam createSystemMessage(String content) { + return ChatCompletionMessageParam.ofChatCompletionSystemMessageParam( + ChatCompletionSystemMessageParam.builder() + .role(ChatCompletionSystemMessageParam.Role.SYSTEM) + .content(ChatCompletionSystemMessageParam.Content.ofTextContent(content)) + .build()); + } + + private static ChatCompletionMessageParam createToolMessage(String response, String id) { + return ChatCompletionMessageParam.ofChatCompletionToolMessageParam( + ChatCompletionToolMessageParam.builder() + .role(ChatCompletionToolMessageParam.Role.TOOL) + .toolCallId(id) + .content(ChatCompletionToolMessageParam.Content.ofTextContent(response)) + .build()); + } + + private static Consumer assertThatDurationIsLessThan(long toNanos) { + return point -> + assertThat(point.getSum()) + .isStrictlyBetween(0.0, (double) toNanos / TimeUnit.SECONDS.toNanos(1L)); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/OpenAIRecordingExtension.java b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/OpenAIRecordingExtension.java new file mode 100644 index 00000000..73d45462 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/OpenAIRecordingExtension.java @@ -0,0 +1,87 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import static com.github.tomakehurst.wiremock.client.WireMock.proxyAllTo; +import static com.github.tomakehurst.wiremock.client.WireMock.recordSpec; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; + +import com.github.tomakehurst.wiremock.common.SingleRootFileSource; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; + +final class OpenAIRecordingExtension extends WireMockExtension { + + /** + * Setting this to true will make the tests call the real OpenAI API instead and record the + * responses. You'll have to setup the OPENAI_API_KEY variable for this to work. + */ + private static final boolean RECORD_WITH_REAL_API = false; + + OpenAIClient client; + + private final String testName; + + OpenAIRecordingExtension(String testName) { + super( + WireMockExtension.newInstance() + .options( + options() + .extensions( + ResponseHeaderScrubber.class, + PrettyPrintEqualToJsonStubMappingTransformer.class) + .mappingSource( + new YamlFileMappingsSource( + new SingleRootFileSource("src/test/resources").child("mappings"))))); + this.testName = testName; + } + + @Override + protected void onBeforeEach(WireMockRuntimeInfo wireMock) { + YamlFileMappingsSource.setCurrentTest(testName); + client = + OpenAIOkHttpClient.builder() + .apiKey(System.getenv().getOrDefault("OPENAI_API_KEY", "testing")) + .baseUrl("http://localhost:" + wireMock.getHttpPort()) + .build(); + + // Set a low priority so recordings are used when available + if (RECORD_WITH_REAL_API) { + String baseUrl = System.getenv().getOrDefault("OPENAI_BASE_URL", "https://api.openai.com/v1"); + stubFor(proxyAllTo(baseUrl).atPriority(Integer.MAX_VALUE)); + startRecording( + recordSpec() + .forTarget(baseUrl) + .transformers("scrub-response-header", "pretty-print-equal-to-json") + // Include all bodies inline. + .extractTextBodiesOver(Long.MAX_VALUE) + .extractBinaryBodiesOver(Long.MAX_VALUE)); + } + } + + @Override + protected void onAfterEach(WireMockRuntimeInfo wireMockRuntimeInfo) { + if (RECORD_WITH_REAL_API) { + YamlFileMappingsSource.setCurrentTest(testName); + stopRecording(); + } + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/PrettyPrintEqualToJsonStubMappingTransformer.java b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/PrettyPrintEqualToJsonStubMappingTransformer.java new file mode 100644 index 00000000..e92962e8 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/PrettyPrintEqualToJsonStubMappingTransformer.java @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import com.github.tomakehurst.wiremock.common.FileSource; +import com.github.tomakehurst.wiremock.extension.Parameters; +import com.github.tomakehurst.wiremock.extension.StubMappingTransformer; +import com.github.tomakehurst.wiremock.matching.ContentPattern; +import com.github.tomakehurst.wiremock.matching.EqualToJsonPattern; +import com.github.tomakehurst.wiremock.stubbing.StubMapping; +import java.util.List; + +public class PrettyPrintEqualToJsonStubMappingTransformer extends StubMappingTransformer { + @Override + public String getName() { + return "pretty-print-equal-to-json"; + } + + @Override + public StubMapping transform(StubMapping stubMapping, FileSource files, Parameters parameters) { + List> patterns = stubMapping.getRequest().getBodyPatterns(); + if (patterns != null) { + for (int i = 0; i < patterns.size(); i++) { + ContentPattern pattern = patterns.get(i); + if (!(pattern instanceof EqualToJsonPattern)) { + continue; + } + EqualToJsonPattern equalToJsonPattern = (EqualToJsonPattern) pattern; + patterns.set( + i, + new EqualToJsonPattern( + equalToJsonPattern.getExpected(), // pretty printed, + // We exact match the request unlike the default. + false, + false)); + } + } + return stubMapping; + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/ResponseHeaderScrubber.java b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/ResponseHeaderScrubber.java new file mode 100644 index 00000000..bc2adcd1 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/ResponseHeaderScrubber.java @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import com.github.tomakehurst.wiremock.common.FileSource; +import com.github.tomakehurst.wiremock.extension.Parameters; +import com.github.tomakehurst.wiremock.extension.ResponseTransformer; +import com.github.tomakehurst.wiremock.http.HttpHeader; +import com.github.tomakehurst.wiremock.http.HttpHeaders; +import com.github.tomakehurst.wiremock.http.Request; +import com.github.tomakehurst.wiremock.http.Response; + +public class ResponseHeaderScrubber extends ResponseTransformer { + @Override + public String getName() { + return "scrub-response-header"; + } + + @Override + public Response transform( + Request request, Response response, FileSource fileSource, Parameters parameters) { + HttpHeaders scrubbed = HttpHeaders.noHeaders(); + for (HttpHeader header : response.getHeaders().all()) { + switch (header.key()) { + case "Set-Cookie": + scrubbed = scrubbed.plus(HttpHeader.httpHeader("Set-Cookie", "test_set_cookie")); + break; + case "openai-organization": + scrubbed = + scrubbed.plus(HttpHeader.httpHeader("openai-organization", "test_openai_org_id")); + break; + default: + scrubbed = scrubbed.plus(header); + break; + } + } + return Response.Builder.like(response).but().headers(scrubbed).build(); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/ValAssert.java b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/ValAssert.java new file mode 100644 index 00000000..86814f6c --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/ValAssert.java @@ -0,0 +1,191 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.common.KeyValue; +import io.opentelemetry.api.common.Value; +import io.opentelemetry.api.common.ValueType; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public interface ValAssert extends Consumer>, BiConsumer> { + + default void accept(Value value) { + accept(null, value); + } + + static ForMap map() { + return new ForMap(); + } + + static ForArray array() { + return new ForArray(); + } + + static ValAssert of(String value) { + return (path, val) -> { + assertThat(val.getType()) + .describedAs(descriptionForPath(path, "to have a string value")) + .isEqualTo(ValueType.STRING); + assertThat((String) val.getValue()) + .describedAs(descriptionForPath(path, "to match")) + .isEqualTo(value); + }; + } + + static ValAssert of(boolean value) { + return (path, val) -> { + assertThat(val.getType()) + .describedAs(descriptionForPath(path, "to have a boolean value")) + .isEqualTo(ValueType.BOOLEAN); + assertThat((boolean) val.getValue()) + .describedAs(descriptionForPath(path, "to match")) + .isEqualTo(value); + }; + } + + static ValAssert of(long value) { + return (path, val) -> { + assertThat(val.getType()) + .describedAs(descriptionForPath(path, "to have a integer value")) + .isEqualTo(ValueType.LONG); + assertThat((Long) val.getValue()) + .describedAs(descriptionForPath(path, "to match")) + .isEqualTo(value); + }; + } + + static ValAssert of(Object value) { + Class valClass = value.getClass(); + if (valClass == Boolean.class) { + return of(((Boolean) value).booleanValue()); + } + if (valClass == Long.class) { + return of(((Long) value).longValue()); + } + if (valClass == Integer.class) { + return of(((Integer) value).longValue()); + } + if (valClass == String.class) { + return of(((String) value)); + } + if (valClass.isArray()) { + Object[] arr = (Object[]) value; + ForArray result = array(); + for (Object entry : arr) { + result.entry(of(entry)); + } + return result; + } + throw new IllegalArgumentException("Unhandled type: " + valClass.getName()); + } + + static String descriptionForPath(String path, String check) { + if (path == null) { + return "Expected AnyValue " + check; + } else { + return "Expected AnyValue under path '" + path + "' " + check; + } + } + + class ForMap implements ValAssert { + + private final Map expectedElements = new LinkedHashMap<>(); + + public ForMap entry(String key, ValAssert value) { + expectedElements.put(key, value); + return this; + } + + public ForMap entry(String key, Object value) { + expectedElements.put(key, ValAssert.of(value)); + return this; + } + + @SuppressWarnings("unchecked") + @Override + public void accept(String parentPath, Value anyValue) { + String pathPrefix = parentPath == null ? "" : parentPath + "."; + assertThat(anyValue.getType()) + .describedAs(descriptionForPath(parentPath, "to be a map")) + .isEqualTo(ValueType.KEY_VALUE_LIST); + expectedElements.forEach( + (key, valueAssert) -> { + Optional first = + ((List) anyValue.getValue()) + .stream().filter(kv -> kv.getKey().equals(key)).findFirst(); + + String path = pathPrefix + key; + assertThat(first) + .describedAs(descriptionForPath(path, "to exist / be non-null")) + .isPresent(); + valueAssert.accept(path, first.get().getValue()); + }); + } + } + + class ForArray implements ValAssert { + + private final List expectedElements = new ArrayList<>(); + private boolean ignoreOrder = false; + + public ForArray entry(ValAssert value) { + expectedElements.add(value); + return this; + } + + public ForArray ignoreOrder() { + ignoreOrder = true; + return this; + } + + @SuppressWarnings("unchecked") + @Override + public void accept(String parentPath, Value anyValue) { + String pathPrefix = parentPath == null ? "" : parentPath; + assertThat(anyValue.getType()) + .describedAs(descriptionForPath(parentPath, "to be an array")) + .isEqualTo(ValueType.ARRAY); + + List> arrayVal = (List>) anyValue.getValue(); + assertThat(arrayVal.size()) + .describedAs(descriptionForPath(parentPath, "to have the exact array size")) + .isEqualTo(expectedElements.size()); + if (ignoreOrder) { + for (ValAssert assertion : expectedElements) { + assertThat(arrayVal) + .describedAs(descriptionForPath(parentPath, "to have any element satisfying")) + .anySatisfy(val -> assertion.accept(pathPrefix + "[x]", val)); + } + } else { + for (int i = 0; i < expectedElements.size(); i++) { + String path = pathPrefix + "[" + i + "]"; + expectedElements.get(i).accept(path, arrayVal.get(i)); + } + } + } + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/YamlFileMappingsSource.java b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/YamlFileMappingsSource.java new file mode 100644 index 00000000..dc1f2b0e --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/YamlFileMappingsSource.java @@ -0,0 +1,212 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai; + +import static com.github.tomakehurst.wiremock.common.AbstractFileSource.byFileExtension; +import static com.github.tomakehurst.wiremock.common.Exceptions.throwUnchecked; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.cfg.JsonNodeFeature; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.github.tomakehurst.wiremock.common.FileSource; +import com.github.tomakehurst.wiremock.common.Json; +import com.github.tomakehurst.wiremock.common.JsonException; +import com.github.tomakehurst.wiremock.common.NotWritableException; +import com.github.tomakehurst.wiremock.common.TextFile; +import com.github.tomakehurst.wiremock.standalone.MappingFileException; +import com.github.tomakehurst.wiremock.standalone.MappingsSource; +import com.github.tomakehurst.wiremock.stubbing.StubMapping; +import com.github.tomakehurst.wiremock.stubbing.StubMappings; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +// Mostly the same as +// https://github.com/wiremock/wiremock/blob/master/src/main/java/com/github/tomakehurst/wiremock/standalone/JsonFileMappingsSource.java +// replacing Json with Yaml. +class YamlFileMappingsSource implements MappingsSource { + + private static final ObjectMapper yamlMapper = + new YAMLMapper() + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + .enable(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS) + // For non-YAML, follow + // https://github.com/wiremock/wiremock/blob/master/src/main/java/com/github/tomakehurst/wiremock/common/Json.java#L41 + .setSerializationInclusion(Include.NON_NULL) + .configure(JsonNodeFeature.STRIP_TRAILING_BIGDECIMAL_ZEROES, false) + .configure(JsonParser.Feature.ALLOW_COMMENTS, true) + .configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true) + .configure(JsonParser.Feature.IGNORE_UNDEFINED, true) + .configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true) + .configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true) + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .enable(JsonParser.Feature.INCLUDE_SOURCE_IN_LOCATION); + + private static final ThreadLocal currentTest = new ThreadLocal<>(); + + private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^\\w-.]"); + + static void setCurrentTest(String testName) { + currentTest.set(testName); + } + + private final FileSource mappingsFileSource; + private final Map fileNameMap; + + YamlFileMappingsSource(FileSource mappingsFileSource) { + this.mappingsFileSource = mappingsFileSource; + fileNameMap = new HashMap<>(); + } + + @Override + public void save(List stubMappings) { + for (StubMapping mapping : stubMappings) { + if (mapping != null && mapping.isDirty()) { + save(mapping); + } + } + } + + @Override + public void save(StubMapping stubMapping) { + StubMappingFileMetadata fileMetadata = fileNameMap.get(stubMapping.getId()); + if (fileMetadata == null) { + String filename = currentTest.get(); + fileMetadata = new StubMappingFileMetadata(sanitize(filename) + ".yaml", false); + } + + if (fileMetadata.multi) { + throw new NotWritableException( + "Stubs loaded from multi-mapping files are read-only, and therefore cannot be saved"); + } + + String yaml = ""; + try { + ObjectWriter objectWriter = + yamlMapper.writerWithDefaultPrettyPrinter().withView(Json.PrivateView.class); + yaml = objectWriter.writeValueAsString(stubMapping); + } catch (IOException ioe) { + throwUnchecked(ioe, String.class); + } + TextFile outFile = mappingsFileSource.getTextFileNamed(fileMetadata.path); + // For multiple requests from the same test, we append as a multi-file yaml. + if (Files.exists(Paths.get(outFile.getPath()))) { + String existing = outFile.readContentsAsString(); + yaml = existing + yaml; + } + mappingsFileSource.writeTextFile(fileMetadata.path, yaml); + + fileNameMap.put(stubMapping.getId(), fileMetadata); + stubMapping.setDirty(false); + } + + @Override + public void remove(StubMapping stubMapping) { + StubMappingFileMetadata fileMetadata = fileNameMap.get(stubMapping.getId()); + if (fileMetadata.multi) { + throw new NotWritableException( + "Stubs loaded from multi-mapping files are read-only, and therefore cannot be removed"); + } + + mappingsFileSource.deleteFile(fileMetadata.path); + fileNameMap.remove(stubMapping.getId()); + } + + @Override + public void removeAll() { + if (anyFilesAreMultiMapping()) { + throw new NotWritableException( + "Some stubs were loaded from multi-mapping files which are read-only, so remove all cannot be performed"); + } + + for (StubMappingFileMetadata fileMetadata : fileNameMap.values()) { + mappingsFileSource.deleteFile(fileMetadata.path); + } + fileNameMap.clear(); + } + + private boolean anyFilesAreMultiMapping() { + return fileNameMap.values().stream().anyMatch(input -> input.multi); + } + + @Override + public void loadMappingsInto(StubMappings stubMappings) { + if (!mappingsFileSource.exists()) { + return; + } + + List mappingFiles = + mappingsFileSource.listFilesRecursively().stream() + .filter(byFileExtension("yaml")) + .collect(Collectors.toList()); + for (TextFile mappingFile : mappingFiles) { + try { + List mappings = + yamlMapper + .readValues( + yamlMapper.createParser(mappingFile.readContentsAsString()), StubMapping.class) + .readAll(); + for (StubMapping mapping : mappings) { + mapping.setDirty(false); + stubMappings.addMapping(mapping); + StubMappingFileMetadata fileMetadata = + new StubMappingFileMetadata(mappingFile.getPath(), mappings.size() > 1); + fileNameMap.put(mapping.getId(), fileMetadata); + } + } catch (JsonProcessingException e) { + throw new MappingFileException( + mappingFile.getPath(), JsonException.fromJackson(e).getErrors().first().getDetail()); + } catch (IOException e) { + throwUnchecked(e); + } + } + } + + private String sanitize(String s) { + String decoratedString = String.join("-", s.split(" ")); + return NON_ALPHANUMERIC.matcher(decoratedString).replaceAll("").toLowerCase(Locale.ROOT); + } + + private static class StubMappingFileMetadata { + final String path; + final boolean multi; + + public StubMappingFileMetadata(String path, boolean multi) { + this.path = path; + this.multi = multi; + } + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/wrappers/InstrumentationSettingsAccessor.java b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/wrappers/InstrumentationSettingsAccessor.java new file mode 100644 index 00000000..bd2334d9 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/java/co/elastic/otel/openai/wrappers/InstrumentationSettingsAccessor.java @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 + * + * http://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 co.elastic.otel.openai.wrappers; + +import com.openai.client.OpenAIClient; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; + +public class InstrumentationSettingsAccessor { + + public static void setEmitEvents(OpenAIClient client, boolean emitEvents) { + InstrumentedOpenAiClient handler = + (InstrumentedOpenAiClient) extractOpenAIClientHandler(client); + + InstrumentationSettings original = handler.settings; + InstrumentationSettings updated = + new InstrumentationSettings( + emitEvents, + original.captureMessageContent, + original.serverAddress, + original.serverPort); + + handler.settings = updated; + } + + public static void setCaptureMessageContent(OpenAIClient client, boolean captureContent) { + InstrumentedOpenAiClient handler = + (InstrumentedOpenAiClient) extractOpenAIClientHandler(client); + + InstrumentationSettings original = handler.settings; + InstrumentationSettings updated = + new InstrumentationSettings( + original.emitEvents, captureContent, original.serverAddress, original.serverPort); + + handler.settings = updated; + } + + private static InvocationHandler extractOpenAIClientHandler(OpenAIClient client) { + if (!(Proxy.isProxyClass(client.getClass()))) { + throw new IllegalArgumentException("client is not a Proxy, but would be if instrumented"); + } + + return Proxy.getInvocationHandler(client); + } +} diff --git a/instrumentation/openai-client-instrumentation/src/test/resources/mappings/chattest.yaml b/instrumentation/openai-client-instrumentation/src/test/resources/mappings/chattest.yaml new file mode 100644 index 00000000..3d8bf361 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/resources/mappings/chattest.yaml @@ -0,0 +1,1239 @@ +--- +id: 3f9cba30-8e45-408c-8f93-8a2f28fc1147 +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "frequency_penalty" : 0, + "max_tokens" : 100, + "presence_penalty" : 0, + "response_format" : { + "type" : "text" + }, + "seed" : 100, + "stop" : [ "foo" ], + "temperature" : 1, + "top_p" : 1 + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "id": "chatcmpl-ApviQlqj8fahybjC0N8MSkPiFt2ls", + "object": "chat.completion", + "created": 1736939950, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Southern Ocean.", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 22, + "completion_tokens": 3, + "total_tokens": 25, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_72ed7ab54c" + } + headers: + Date: "Wed, 15 Jan 2025 11:19:10 GMT" + Content-Type: application/json + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "168" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9999" + x-ratelimit-remaining-tokens: "199883" + x-ratelimit-reset-requests: 8.64s + x-ratelimit-reset-tokens: 34ms + x-request-id: req_6c7842b1c3f10b8637bb26050a52edb4 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 902578210b55d269-FRA +uuid: 3f9cba30-8e45-408c-8f93-8a2f28fc1147 +persistent: true +insertionIndex: 2 +--- +id: 1d03b2c6-450b-4dfc-8c66-65007ac6662b +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini" + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "id": "chatcmpl-ApviRmVhVukgmmFTXvHvlvgmBf6Fr", + "object": "chat.completion", + "created": 1736939951, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "South Atlantic Ocean.", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 22, + "completion_tokens": 5, + "total_tokens": 27, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_72ed7ab54c" + } + headers: + Date: "Wed, 15 Jan 2025 11:19:11 GMT" + Content-Type: application/json + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "454" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9998" + x-ratelimit-remaining-tokens: "199967" + x-ratelimit-reset-requests: 16.283s + x-ratelimit-reset-tokens: 9ms + x-request-id: req_ea9e3f4cc3b7f2162e1451ccd969e674 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 902578271bdc30f6-FRA +uuid: 1d03b2c6-450b-4dfc-8c66-65007ac6662b +persistent: true +insertionIndex: 6 +--- +id: 4d01d762-0339-43c1-8dc8-24b44e8df5ea +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "You are a helpful assistant providing weather updates.", + "role" : "system" + }, { + "content" : "What is the weather in New York City and London?", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "tools" : [ { + "type" : "function", + "function" : { + "name" : "get_weather", + "parameters" : { + "type" : "object", + "required" : [ "location" ], + "additionalProperties" : false, + "properties" : { + "location" : { + "description" : "The location to get the current temperature for", + "type" : "string" + } + } + } + } + } ] + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "id": "chatcmpl-ApviSs94mF5FrHZHISwvYcpDclw4O", + "object": "chat.completion", + "created": 1736939952, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_praw3bINJa8YgORLR1j5YqAd", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"New York City\"}" + } + }, + { + "id": "call_wBr1xgbiWQaqY0D0enFzmkR1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"London\"}" + } + } + ], + "refusal": null + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 67, + "completion_tokens": 47, + "total_tokens": 114, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_bd83329f63" + } + headers: + Date: "Wed, 15 Jan 2025 11:19:13 GMT" + Content-Type: application/json + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "965" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9997" + x-ratelimit-remaining-tokens: "199956" + x-ratelimit-reset-requests: 24.043s + x-ratelimit-reset-tokens: 13ms + x-request-id: req_461add22df2e5e30c211828be358cd63 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 9025782c9cf31da6-FRA +uuid: 4d01d762-0339-43c1-8dc8-24b44e8df5ea +persistent: true +insertionIndex: 11 +--- +id: 0f1e52b2-bf92-4f44-8223-5dd7d93bca36 +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "You are a helpful assistant providing weather updates.", + "role" : "system" + }, { + "content" : "What is the weather in New York City and London?", + "role" : "user" + }, { + "content" : "", + "role" : "assistant", + "tool_calls" : [ { + "id" : "call_praw3bINJa8YgORLR1j5YqAd", + "type" : "function", + "function" : { + "name" : "get_weather", + "arguments" : "{\"location\": \"New York City\"}" + } + }, { + "id" : "call_wBr1xgbiWQaqY0D0enFzmkR1", + "type" : "function", + "function" : { + "name" : "get_weather", + "arguments" : "{\"location\": \"London\"}" + } + } ] + }, { + "role" : "tool", + "content" : "25 degrees and sunny", + "tool_call_id" : "call_praw3bINJa8YgORLR1j5YqAd" + }, { + "role" : "tool", + "content" : "15 degrees and raining", + "tool_call_id" : "call_wBr1xgbiWQaqY0D0enFzmkR1" + } ], + "model" : "gpt-4o-mini" + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "id": "chatcmpl-ApviUidJQ9YrYP5xeNzIt8EVfYbFW", + "object": "chat.completion", + "created": 1736939954, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The current weather in New York City is 25 degrees Celsius and sunny. In London, it is 15 degrees Celsius and raining.", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 99, + "completion_tokens": 27, + "total_tokens": 126, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_72ed7ab54c" + } + headers: + Date: "Wed, 15 Jan 2025 11:19:14 GMT" + Content-Type: application/json + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "1200" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9996" + x-ratelimit-remaining-tokens: "199943" + x-ratelimit-reset-requests: 31.232s + x-ratelimit-reset-tokens: 17ms + x-request-id: req_feb030ccccced2bf6afe7c40c72d7411 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 90257835aaeb3603-FRA +uuid: 0f1e52b2-bf92-4f44-8223-5dd7d93bca36 +persistent: true +insertionIndex: 12 +--- +id: fcb1f170-582b-4213-8905-088801bd5da1 +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini" + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "id": "chatcmpl-ApvifdVpIRcgHgDW6sFspxbSUOPji", + "object": "chat.completion", + "created": 1736939965, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "South Atlantic Ocean.", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 22, + "completion_tokens": 5, + "total_tokens": 27, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_72ed7ab54c" + } + headers: + Date: "Wed, 15 Jan 2025 11:19:25 GMT" + Content-Type: application/json + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "289" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9996" + x-ratelimit-remaining-tokens: "199967" + x-ratelimit-reset-requests: 28.224s + x-ratelimit-reset-tokens: 9ms + x-request-id: req_558428698c1c501afd73150c5949a976 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 9025787e89d9903a-FRA +uuid: fcb1f170-582b-4213-8905-088801bd5da1 +persistent: true +insertionIndex: 19 +--- +id: f1c29c6d-77f6-416b-8d67-3af3bf41748c +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "stream" : true + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: |+ + data: {"id":"chatcmpl-ApvigJsNSpkH4VnSxDSWzfAyc8uy4","object":"chat.completion.chunk","created":1736939966,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvigJsNSpkH4VnSxDSWzfAyc8uy4","object":"chat.completion.chunk","created":1736939966,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":"South"},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvigJsNSpkH4VnSxDSWzfAyc8uy4","object":"chat.completion.chunk","created":1736939966,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":" Atlantic"},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvigJsNSpkH4VnSxDSWzfAyc8uy4","object":"chat.completion.chunk","created":1736939966,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":" Ocean"},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvigJsNSpkH4VnSxDSWzfAyc8uy4","object":"chat.completion.chunk","created":1736939966,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvigJsNSpkH4VnSxDSWzfAyc8uy4","object":"chat.completion.chunk","created":1736939966,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]} + + data: [DONE] + + headers: + Date: "Wed, 15 Jan 2025 11:19:26 GMT" + Content-Type: text/event-stream; charset=utf-8 + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "320" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9995" + x-ratelimit-remaining-tokens: "199967" + x-ratelimit-reset-requests: 36.155s + x-ratelimit-reset-tokens: 9ms + x-request-id: req_04002595aba2c59a6bc5ec701a16c2da + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 90257882d8ff9730-FRA +uuid: f1c29c6d-77f6-416b-8d67-3af3bf41748c +persistent: true +insertionIndex: 27 +--- +id: 6c176fd9-33ef-43dc-95e1-b41302ac459b +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "frequency_penalty" : 0, + "max_tokens" : 100, + "presence_penalty" : 0, + "response_format" : { + "type" : "text" + }, + "seed" : 100, + "stop" : [ "foo" ], + "temperature" : 1, + "top_p" : 1, + "stream" : true + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: |+ + data: {"id":"chatcmpl-ApvihZr5if54NlG1FkM0FLYadGaZW","object":"chat.completion.chunk","created":1736939967,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_bd83329f63","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvihZr5if54NlG1FkM0FLYadGaZW","object":"chat.completion.chunk","created":1736939967,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_bd83329f63","choices":[{"index":0,"delta":{"content":"Southern"},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvihZr5if54NlG1FkM0FLYadGaZW","object":"chat.completion.chunk","created":1736939967,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_bd83329f63","choices":[{"index":0,"delta":{"content":" Ocean"},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvihZr5if54NlG1FkM0FLYadGaZW","object":"chat.completion.chunk","created":1736939967,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_bd83329f63","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvihZr5if54NlG1FkM0FLYadGaZW","object":"chat.completion.chunk","created":1736939967,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_bd83329f63","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]} + + data: [DONE] + + headers: + Date: "Wed, 15 Jan 2025 11:19:27 GMT" + Content-Type: text/event-stream; charset=utf-8 + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "232" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9994" + x-ratelimit-remaining-tokens: "199883" + x-ratelimit-reset-requests: 43.871s + x-ratelimit-reset-tokens: 34ms + x-request-id: req_143a0a089d19b5550f77633653d093c7 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 90257888caa4dc74-FRA +uuid: 6c176fd9-33ef-43dc-95e1-b41302ac459b +persistent: true +insertionIndex: 36 +--- +id: 86161b62-8793-4a43-9231-2c0c4719f212 +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "You are a helpful customer support assistant. Use the supplied tools to assist the user.", + "role" : "system" + }, { + "content" : "Hi, can you tell me the delivery date for my order?", + "role" : "user" + }, { + "content" : "Hi there! I can help with that. Can you please provide your order ID?", + "role" : "assistant" + }, { + "content" : "i think it is order_12345", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "tools" : [ { + "type" : "function", + "function" : { + "description" : "Get the delivery date for a customer's order. Call this whenever you need to know the delivery date, for example when a customer asks 'Where is my package'", + "name" : "get_delivery_date", + "parameters" : { + "type" : "object", + "required" : [ "order_id" ], + "additionalProperties" : false, + "properties" : { + "order_id" : { + "description" : "The customer's order ID.", + "type" : "string" + } + } + } + } + } ] + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "id": "chatcmpl-ApvihK7kXkNWtzppIkF4fFwxNUlfq", + "object": "chat.completion", + "created": 1736939967, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_FmRndu0ugAh8YjL9oUoJhWAh", + "type": "function", + "function": { + "name": "get_delivery_date", + "arguments": "{\"order_id\":\"order_12345\"}" + } + } + ], + "refusal": null + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 140, + "completion_tokens": 20, + "total_tokens": 160, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_72ed7ab54c" + } + headers: + Date: "Wed, 15 Jan 2025 11:19:28 GMT" + Content-Type: application/json + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "425" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9994" + x-ratelimit-remaining-tokens: "199921" + x-ratelimit-reset-requests: 51.776s + x-ratelimit-reset-tokens: 23ms + x-request-id: req_807eb46408ee64b75533fed721c20a31 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 9025788d4bc237d4-FRA +uuid: 86161b62-8793-4a43-9231-2c0c4719f212 +persistent: true +insertionIndex: 46 +--- +id: ca660e30-0e44-46fa-9133-7eaf446e9ad2 +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini" + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "id": "chatcmpl-Apviihciys1xpuAyFIR97rqKK8gfi", + "object": "chat.completion", + "created": 1736939968, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Southern Ocean", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 22, + "completion_tokens": 3, + "total_tokens": 25, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_bd83329f63" + } + headers: + Date: "Wed, 15 Jan 2025 11:19:28 GMT" + Content-Type: application/json + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "195" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9993" + x-ratelimit-remaining-tokens: "199967" + x-ratelimit-reset-requests: 59.512s + x-ratelimit-reset-tokens: 9ms + x-request-id: req_53cec6fba0cc88410bb818e78d4c843e + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 90257892fe73dcba-FRA +uuid: ca660e30-0e44-46fa-9133-7eaf446e9ad2 +persistent: true +insertionIndex: 57 +--- +id: 358a4259-7ba6-49f9-b4bf-e2884e69e80b +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "stream_options" : { + "include_usage" : true + }, + "stream" : true + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: |+ + data: {"id":"chatcmpl-ApvijNaIbvnhtW8P4xCz9SJjFD4Uq","object":"chat.completion.chunk","created":1736939969,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ApvijNaIbvnhtW8P4xCz9SJjFD4Uq","object":"chat.completion.chunk","created":1736939969,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":"South"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ApvijNaIbvnhtW8P4xCz9SJjFD4Uq","object":"chat.completion.chunk","created":1736939969,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":" Atlantic"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ApvijNaIbvnhtW8P4xCz9SJjFD4Uq","object":"chat.completion.chunk","created":1736939969,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":" Ocean"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ApvijNaIbvnhtW8P4xCz9SJjFD4Uq","object":"chat.completion.chunk","created":1736939969,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ApvijNaIbvnhtW8P4xCz9SJjFD4Uq","object":"chat.completion.chunk","created":1736939969,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} + + data: {"id":"chatcmpl-ApvijNaIbvnhtW8P4xCz9SJjFD4Uq","object":"chat.completion.chunk","created":1736939969,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[],"usage":{"prompt_tokens":22,"completion_tokens":4,"total_tokens":26,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} + + data: [DONE] + + headers: + Date: "Wed, 15 Jan 2025 11:19:29 GMT" + Content-Type: text/event-stream; charset=utf-8 + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "314" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9992" + x-ratelimit-remaining-tokens: "199967" + x-ratelimit-reset-requests: 1m7.606s + x-ratelimit-reset-tokens: 9ms + x-request-id: req_bff585d123188cdc9028a65bacdb0538 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 902578965e1bdc5a-FRA +uuid: 358a4259-7ba6-49f9-b4bf-e2884e69e80b +persistent: true +insertionIndex: 69 +--- +id: cebd91eb-be1a-4246-91be-a5a518feb0a9 +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "You are a helpful customer support assistant. Use the supplied tools to assist the user.", + "role" : "system" + }, { + "content" : "Hi, can you tell me the delivery date for my order?", + "role" : "user" + }, { + "content" : "Hi there! I can help with that. Can you please provide your order ID?", + "role" : "assistant" + }, { + "content" : "i think it is order_12345", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "tools" : [ { + "type" : "function", + "function" : { + "description" : "Get the delivery date for a customer's order. Call this whenever you need to know the delivery date, for example when a customer asks 'Where is my package'", + "name" : "get_delivery_date", + "parameters" : { + "type" : "object", + "required" : [ "order_id" ], + "additionalProperties" : false, + "properties" : { + "order_id" : { + "description" : "The customer's order ID.", + "type" : "string" + } + } + } + } + } ] + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "id": "chatcmpl-Apvile0yBKxo1xS0aexIJkmR0Qjn1", + "object": "chat.completion", + "created": 1736939971, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_q1MEs5sEuhADqC1WRvne6VRk", + "type": "function", + "function": { + "name": "get_delivery_date", + "arguments": "{\"order_id\":\"order_12345\"}" + } + } + ], + "refusal": null + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 140, + "completion_tokens": 20, + "total_tokens": 160, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_72ed7ab54c" + } + headers: + Date: "Wed, 15 Jan 2025 11:19:32 GMT" + Content-Type: application/json + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "515" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9991" + x-ratelimit-remaining-tokens: "199921" + x-ratelimit-reset-requests: 1m13.799s + x-ratelimit-reset-tokens: 23ms + x-request-id: req_9e1675aea9843f1e0c7d216f96f89855 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 902578a5ad1a691f-FRA +uuid: cebd91eb-be1a-4246-91be-a5a518feb0a9 +persistent: true +insertionIndex: 94 +--- +id: 33095242-9962-4d27-81cb-09b6cfa2e8da +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "stream" : true + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: |+ + data: {"id":"chatcmpl-ApvimUEw4ey6z52Vo4z76UTUiPTAF","object":"chat.completion.chunk","created":1736939972,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvimUEw4ey6z52Vo4z76UTUiPTAF","object":"chat.completion.chunk","created":1736939972,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":"South"},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvimUEw4ey6z52Vo4z76UTUiPTAF","object":"chat.completion.chunk","created":1736939972,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":" Atlantic"},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvimUEw4ey6z52Vo4z76UTUiPTAF","object":"chat.completion.chunk","created":1736939972,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":" Ocean"},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvimUEw4ey6z52Vo4z76UTUiPTAF","object":"chat.completion.chunk","created":1736939972,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvimUEw4ey6z52Vo4z76UTUiPTAF","object":"chat.completion.chunk","created":1736939972,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]} + + data: [DONE] + + headers: + Date: "Wed, 15 Jan 2025 11:19:32 GMT" + Content-Type: text/event-stream; charset=utf-8 + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "255" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9990" + x-ratelimit-remaining-tokens: "199967" + x-ratelimit-reset-requests: 1m21.437s + x-ratelimit-reset-tokens: 9ms + x-request-id: req_947d002fda44ce948ff47b98637e1715 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 902578abdf5d4d43-FRA +uuid: 33095242-9962-4d27-81cb-09b6cfa2e8da +persistent: true +insertionIndex: 108 +--- +id: 8995a2ce-d896-4e5c-9e7a-337ca6f09eba +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "n" : 2 + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "id": "chatcmpl-ApvinZ5v2SpojKFd7QM2eCcASAnl5", + "object": "chat.completion", + "created": 1736939973, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Atlantic Ocean.", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + }, + { + "index": 1, + "message": { + "role": "assistant", + "content": "South Atlantic Ocean.", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 22, + "completion_tokens": 9, + "total_tokens": 31, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_72ed7ab54c" + } + headers: + Date: "Wed, 15 Jan 2025 11:19:33 GMT" + Content-Type: application/json + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "465" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9989" + x-ratelimit-remaining-tokens: "199952" + x-ratelimit-reset-requests: 1m29.236s + x-ratelimit-reset-tokens: 14ms + x-request-id: req_b61dcec32e2c5fb3e358673fedff3c90 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 902578b12a68366f-FRA +uuid: 8995a2ce-d896-4e5c-9e7a-337ca6f09eba +persistent: true +insertionIndex: 123 +--- +id: 59d444c7-bc5a-497a-8a5d-74864d7d11c0 +name: chat_completions +request: + url: /chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "You are a helpful customer support assistant. Use the supplied tools to assist the user.", + "role" : "system" + }, { + "content" : "Hi, can you tell me the delivery date for my order?", + "role" : "user" + }, { + "content" : "Hi there! I can help with that. Can you please provide your order ID?", + "role" : "assistant" + }, { + "content" : "i think it is order_12345", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "tools" : [ { + "type" : "function", + "function" : { + "description" : "Get the delivery date for a customer's order. Call this whenever you need to know the delivery date, for example when a customer asks 'Where is my package'", + "name" : "get_delivery_date", + "parameters" : { + "type" : "object", + "required" : [ "order_id" ], + "additionalProperties" : false, + "properties" : { + "order_id" : { + "description" : "The customer's order ID.", + "type" : "string" + } + } + } + } + } ], + "stream" : true + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: |+ + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_U1rxxS68HLPkdVOpTVYDUtQz","type":"function","function":{"name":"get_delivery_date","arguments":""}}],"refusal":null},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"order"}}]},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"_id"}}]},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"order"}}]},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"_"}}]},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"123"}}]},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"45"}}]},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}]} + + data: {"id":"chatcmpl-ApvioLCkN5XjQxUO9zFlOUUG3dAHq","object":"chat.completion.chunk","created":1736939974,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_72ed7ab54c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]} + + data: [DONE] + + headers: + Date: "Wed, 15 Jan 2025 11:19:34 GMT" + Content-Type: text/event-stream; charset=utf-8 + access-control-expose-headers: X-Request-ID + openai-organization: test_openai_org_id + openai-processing-ms: "376" + openai-version: 2020-10-01 + x-ratelimit-limit-requests: "10000" + x-ratelimit-limit-tokens: "200000" + x-ratelimit-remaining-requests: "9988" + x-ratelimit-remaining-tokens: "199921" + x-ratelimit-reset-requests: 1m37.071s + x-ratelimit-reset-tokens: 23ms + x-request-id: req_4adf8d6eda1aba18b3f09004daae99b6 + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 902578b63b448ecb-FRA +uuid: 59d444c7-bc5a-497a-8a5d-74864d7d11c0 +persistent: true +insertionIndex: 139 +--- +id: 02ad43a7-4627-4cc7-b5e6-538c9ff83b53 +name: bad-url_chat_completions +request: + url: /bad-url/chat/completions + method: POST + bodyPatterns: + - equalToJson: |- + { + "messages" : [ { + "content" : "Answer in up to 3 words: Which ocean contains Bouvet Island?", + "role" : "user" + } ], + "model" : "gpt-4o-mini", + "stream" : true + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 404 + body: "\r\n404 Not Found\r\n\r\n

404\ + \ Not Found

\r\n
nginx
\r\n\r\n\r\ + \n" + headers: + Date: "Wed, 15 Jan 2025 11:19:35 GMT" + Content-Type: text/html + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 902578bc1bf1dbd3-FRA +uuid: 02ad43a7-4627-4cc7-b5e6-538c9ff83b53 +persistent: true +insertionIndex: 156 diff --git a/instrumentation/openai-client-instrumentation/src/test/resources/mappings/embeddingstest.yaml b/instrumentation/openai-client-instrumentation/src/test/resources/mappings/embeddingstest.yaml new file mode 100644 index 00000000..0d6acbe7 --- /dev/null +++ b/instrumentation/openai-client-instrumentation/src/test/resources/mappings/embeddingstest.yaml @@ -0,0 +1,100 @@ +--- +id: dce3d5d1-803c-48f5-97cb-537b7aa4ac63 +name: embeddings +request: + url: /embeddings + method: POST + bodyPatterns: + - equalToJson: |- + { + "input" : [ "South Atlantic Ocean." ], + "model" : "text-embedding-3-small", + "encoding_format" : "base64" + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 200 + body: | + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "wDYBvZfm9rzEQSg8pHoVPKTZ/LwQv4Q9sJ4TPR3g+bq3f5q74OGrvPn4LztJgTO8wBqZvNShRbwEyXU8vFnJPP/uxryDl5o8xxeIPG+LvTwteOe8iNoRvcO/gL1OP3o9ZgynPGyZdbzQsgY8cJKVPOuDgr0NsdQ8+3rXParr+7wVMGs60LKGPVkZIT1Edow9zdymvCltQLxwDWW9zSkHPAkM7TxFwGO9CG5dO4nFATtQmgo8MGqvO3XVjL2JxQG9/lcPPRtCajxH/wu98fToPEOnBL32bzC9J88wPRnV0rxpLF68v/4wO2/DjT1UUfm5KW1AvZEMSD1E8du6+7Knu9nkPL3MDR+7/RhnPOBcez05nGW9h++hvJ0wRr0Ph7Q81KHFPKcDFb0OgFy9ry5zvFOCcbx2pBS99JnQunJMDb0cEfK896eAPZIosDxJZUu8yBR/vNE0Lr0Qv4Q8hwsKPM/HlrzvVtk80zsGvYU1qrwRQay8SLIrvDg2JryQiiA9J+sYvL0oUTzUocU81neluwUBxrzQLda8tZSqPKFsZbwnZui8ecTLvCIj8TrZACU8qiNMPBnxOrynAxW8IiPxPDLs1rqDl5o8WEqZuw64rLz+Hz87rgCEvaFsZT3RNK47q3cEPAjCFb0z8648AytmvC39F71sAj68Ny/OvMRBKLygnd07ecRLO67kGzw9QU28SbmDvDb3/bwQcqQ8I5ORPavy07tYLrG8aZWmOhc3QzyrDrw8CT3lPMHpID1L0mI72QClPGAWkLve72O9AlzePHW5pDw9eR29VidZPCKoobsFmH07STTTPLFtm7sZ1VK8FTBrux40sjvBnEA8avvlundXNDuohby8wocwPX4HwzyKlIm8FqALvLjl2Tsq0388PRBVvdxtPLzQsga8V1+pvL0o0bxKbKO7fJorvB2BEr1YShk8uiQCvdvrlLxWrIk8BR2uvH8/EztO4BI96+Lpu/Al4TvnDxM9HWUqPKz5K7xZNYm7SZ0bvAwanTwS9Mu89byQPY/XgDxR5OG7OBo+vWr7ZTz0tTg9PZUFvCjWCD0teOc8QbW8vO/bCb1mh3a96kuyvGLQB711UFy9be2tPOoTYj1b0xg61sSFPPMCmbwHn9U8GQ2jPLjl2by3FlK7K1+IvDdnHjxazMA8sDVLvBdvE70OT2Q8WGaBPJOq1zxKA1u8T0ZSPcy5Zj1fD7i8cWEdPOfCsrwIbt08/lePPNAt1rsEag69epNTvaOPJb3QSb67nGE+PDkFrjxZGSE8QH3svK5fa7wl+VA9QUz0PN0gXLygBqa8OBo+PfrjHz0Qv4S8PUFNPc0pB7wK23S8QyLUPI+fMDzRNC48UjgavCsLUL0myFi9GQ0jvSH1gbyAwTq9E9+7vNShRb3WdyU8T3fKPOemyrti0Ac9sDXLO6/PC73Ch7C8E8NTO1I4Gj0Avs47Vd2BO2r75Txjnw897bjJPAN/njyl/Dw8fz+Tu62Q4zzSy2W9wQWJvZLbTzwG0E28KtN/vKm9DDxX9uC8yoQfPWjiBj3N3KY9sDXLPCYAqbzo+oK8B5/VvJjtzjs2fK48QH3suqd+5Lzxw3C7aSxePbI8o7zL6l48YtCHvCVNCTz1vBA9onM9vX2hgzxIlsM8rcgzPMpTpzxZlHA6AeEOPTJAj7yVzRc94MVDvL/GYL3PXk676+JpO5qL3rwLE0U8lx7HPBL0y7wLE0U8+3rXuXIUPb3JTM+8u4rBvPncxzxUUXm85dDqOvVo2DsYIjM9R+MjvD1dtTy4TiK90zsGvfH06Dz1vBC9wjN4Pcm1F7wIwpW8xt+3PGeOzjxH4yO8btgdvGRul7x37ms8t3+aPTb3fbw72w08Ioy5O83Avrua9Ca8VLrBvLYP+jzKoAc9BGoOvd2ljL0FHa48cX0FvahNbDvxlQG96URaOozlOD3UocW8wbiou4jaETucygY81ls9vLk5Er1ChEQ9bm9VvQwanTzl0Gq77dQxPRPDU72KK8E6cX0FvWD6p7oDYza9yhtXPb/G4Ly+YKE78cPwPPt6V71QFVo7Ozr1vPvOj7sHn9W8CZEdvUTx27xsOg497dQxO4eGWbtmKA88cHYtPbCek7x3cxy9kQxIPXheDD2Ued+824LMu03ZurzV2RU8yEV3PLRxar23FtI8lYC3O82IbrwD+m26CG5dvWyZdbuEggo9UwciOT5IJT3UOH28STRTPSOTkTsf59E8QdGku0vSYj0vFve7gfmKu3j1QzwLS5U9bDoOPfKS+LyJJGk88ZWBPKFsZT1g+ie8l+b2O5fm9jwZKYu8f9bKPAMr5rtPRlI8/4X+vIzlODyR1Hc7aZWmPLcyOjrXkw084ugDvOZu+rpu9AU9m8OuPPY3YL2CyJK83SBcPRBypLv4DcC7KwtQPOXQ6jz9bJ+8o6sNvCaX4Dy9KFE8qIU8PY0dCTxcvoi8XQhgvXFFNTxBTPQ8AyvmPAbQTTqZQYc9VNapuwRqDrykLbW8/yYXvNms7Dy7UnE8inihvKhNbDsLqvy7zdymvPBdMb1YZgG9kxMgu0Mi1LtMoeq82N1kvGqAlrxryu28tPaau/Y3YDwwai+9aF3WvLcyujwh9QE9cX0FvCjWCLw1yQ49+CkovSUVOb0VmbO8JshYPe24Sb12iCw91dmVOx1JQrxvi707tPaaPf1QN72+fIm8uOVZvXqT07zsgHk8iamZutkAJT0vt4+8iNoRva5f67vyM5G7bNFFvO6H0TzPXs66x66/utpK/LvRNK68osCdvCDuKbt4Jjy9NpiWurRx6rzWP9W7PxetvCp0GD1MJhs8oJ3dPNpKfLzuDAK8stPavMKHsLwKYCW9nCluPNnkPLwWoAs9wBoZPGEBgDzln3K8xdjfPBr4Eryai947aoCWvJa4B7ytyDM9BDK+PAYInrzMPhc62N1kPEFM9LsDYza7V3sRvYAqAzzO+I68rZBjvHVQXDzxw/A8zSmHvDE5t7zkVRu8abEOPEB9bD317Yg87fCZvN0gXLrykvg88vvAPGtrBj3PXk68n87Vu4eG2Tvm18I8kKYIPfW8kLzr4mm82axsPKiFvLxWdLm8f556vAz+ND2kEc27ePVDPJxhvrx0BoW7faEDPUMi1DtdCGA9OZzlvCaXYLxm8L68odWtvHheDLzLIi89jR0JvJEMSDyeTC49cJKVPH/WyrxH/4u8FoSjuz5kDb21xSI6lhdvPOzpwbxnVn68LBKoPF8PODxMCjM9juyQPO6HUb1VpTE8xAlYPPH0aL0ixAk9dh/kO5oQj7z9GOe7+dzHPEFM9DzrgwI8+7KnvJ7Hfbtg3r+8aF1WPbA1yztO4BI7m9+WvLqD6TwPo5w8KDXwO1mU8Ls5IZY8J+uYvKojzDwgtlk9eF4MPDT6Br2M5bg8x3ZvPGHJLztSs2k888pIvBL0SzzHdm+8a8rtvFS6QTxyTA09m9+WPP1QNzxfK6C8nCnuvB87Cr1Xe5E8kxMgvZYX77z+V4+7L7cPvORVGz1MJps81ls9vQWYfbzWW727hwsKvAJc3roOT+Q89m8wvYHdIj1H4yO810YtPaI77TvorSI9d3Ocu3qT0zzhsDO8q/JTPPW8EDqEggq9j264PCaX4LsW//I8Xw84vNIDtro8ckW8kvc3vK7kG7x+B0M9oJ3dvOiROj2JXLk8VPKRPOviaTwkYpk8IO6pvKTZ/LoBxaY8pjSNvIzlOD3vjqm7cuNEPHi98zvPXs67PUFNvCR+Abw3/lU6MiSnPIYEMj1fR4g85Z9yPH4HQ73Wd6U7ME7HOthiFbwqWDC8vi+pPNrPLL2JJOm7SM4TPUHRJDxHx7u81j9VPA02BT111Qw82rPEPPowADs/M5U6L5unuzoMhrznD5M85XGDOfrjHzyS2088ofEVPdjd5LsqWLC8TsSqPGB1dzufzlU8CxPFOjg2pjxIlkM8nv/NPH/WSrszima8T8uCvH4HQz0UYWO7Fv/yvPn4rzuAKoM8VLrBPLQSA73xw3C87odRPFqbSDwNBQ29bNFFu9WMtbu6CJo8ODYmPUf/izz7sqc8MiSnuV2NkLx4Xgw8bm9VPStDoDxNEYs86HXSvJCKILuTLwg9bWh9u3vLoztZlPC7R8e7POAujLzdINy7T3fKuk4/+rwW0QM8SLIru2ODpzpDU8w62rPEPNM7Brxzevw8ofGVOx1Jwjs8csW6ivPwvBkNo7yWF2898mQJPSVNCT2ntjS9uiQCvbokgjzANgE9JhwRvIHdIrs+SCW9yWg3O1VYUT2WnJ+7iitBPYwWMbwac+K7LswfvDdLNjqo7gS9746pPGLQBz2P1wC8JCrJPMcXCDz+Hz+8rl9rPE93yjyEGUI8kQzIPCkE+DqNmFg9Nvf9O/c+OD27pqm81Gl1PApEvbx/nno9J2boOzEdz7x37mu86+JpPOHMG727UvG8vvfYvENTzLxfpu88bJl1PcA2ATsapFq8qO4EOoOXmjxlPZ886HVSPOs2Irv8gS88GD4bvG8+XTwgtlm84Fz7vKjuBL23FtI8Tdm6vK8u87s5Ba67owr1vJ/OVbzOV3a7nX0mPKxGDDyQpgi9ArCWvHmMezz+Vw+8u4rBPK+CqzySRBi8HlAavHktFD1lITe9ZvC+vF0IYLzNiO47vHWxPCN3qTwvf7+71XDNPDkhFj1mh3Y8nv9NvVvTGD1rMza9MGovPBWZszx/Iys8LuiHvA5P5DxN2bo8iL6pPOxSijkwTse7Hx8ivAziTL1Ss+m8KliwvFTykTwJDO262U2FvItHqTxtaP06qaEkve6juTu0ceo8brw1PJWxrzoVmTM9iY2xPBP7I70h9QE9NmDGPDEdTzxPywI9dh9kOzTeHru+fIm8xiyYOzE5N7t5xMs8sycTvOfCsrziY9O8S9JiPN7v4zrm10K8o4+lO1OC8bzSAza8iY0xPDTCtrrNKYc8RjCEO+ZAizme/828gCoDPRagCzubkjY8gsgSvLA1Sz1imLe7avvlPB3g+bxEPjy809K9vKavXDxo4gY9lUjnOxg+G72BdNo8WMXoPKcDFT3kAWO7FzdDvA3NvDu3mwI94bCzOxurMjtZlHC9abEOO4JDYjtra4Y8chS9uyQqyTr53Me7eL3zvD2VBTvdIFw9L38/vZ0wRrzV2ZW8T0ZSPa9mQz27UnG7WMXoPF54ADxi0Ic7OSEWvb9Lkbwpiai7YxrfPGzRxbzb6xS9Nvd9u+zpwTtQfiK8DOLMPNPSvTz2N+C8AY1WOnvnC71Qmgo9x3bvPNvrlDy8whG8q3eEPOWfcrtn2668ME5HO9Q4fbxPd0q8tkdKvMy5ZrwcEfK7fji7PMVdkLxVick7qE1sPAmRnbwdgRK9zD4XPfncxzw3L848+dzHPAJcXr1g3r88HBFyu3KrdL2xILs8rMFbPXqT0zueGzY9vZEZvJKj/7wsLpA7UU0qO4YEsjsUkls9ODamvLx1MbzuDIK8Ro9rOrk5kjwBxSY8oWzlPCmJKLyVsa+7QbU8O7okgj0OgFw8P65ku0pQu7tbatC7FTBrvOqYkjyEggq8YkvXOfhFkLwvfz+8rpc7OhZoOzz+tvY8tXhCvCg1cL1QFVo8BGqOPD1Bzbyw/fo7oaS1PFhKGbvtuEk8kkQYufPKyLzqZxq864OCPKhN7DyWT788SJZDvRPD0zviY9M8EhA0PdCyBjzxlYE8aZUmvTBqr7wlMaG8oJ3du0YwhDzi6IM9d+7rvF5AMD1nVn478vtAvAfXJbywNcs8teEKvWTp5rt/nvq8RHaMvEiyK7y8Wck8Zod2u4N7Mj3MueY6UjiavPn4r7sYWoM8fgfDuSfrGLztIZI8mUGHuyyp37vuDAI9CZGdvN2lDDwjW0G8dbkkPad+ZDsiqKG8GCKzvJr0Jr0/rmQ6G8cavfvOD7ve7+O8U4LxvIxOgbsIbt28O7+lPEjOkzzoddK8j9eAu7SpOjtSHDK831+Eu6OrjTuRdZA8p37kvBdvk7wIbl07tUByvOoT4jwFOZa7gCqDPFKz6TtSVAK84uiDvMgU/7xYLjE95SQjPVFpEj1FRZQ8nGG+O6cDlTyWF+88CQztPGDevz1R5GE8+XP/vEUprLxCG/w8gsgSvOGws7wAvs486+JpPIvCeDx8MWM8mDqvO5CKoDyqjJQ6s9oyulk1CT3ImS89x3bvOvqrTz3zyki81j9VOxnV0rzxw/C8/lcPPf/uRjxLO6s8+HYIOvFIobwgCpI8Buw1vK/PCzzNiO68LsyfO9kApTyXHke9c3r8PBG8e7xN9SI6STTTvCOTET2dmY46Q1NMvO6H0bxm8D68tPYavBTmk7umGKW51j9Vu3+eerzHrj877odRvCUVOT0jW8G6rl9rPBP7I72RWai805rtOpZPv7xBTHQ8VLrBvENTTDy0ceo7h4bZPOZAiz01yQ478vvAvMeuv7zpyQq8WsxAvIOzgrxyTI27txZSvPuypzzr4mm8VVjROxTKqztMJhs9tBIDvSDuKb2pcCw9m9+WvPfVb7xAAh09IAqSOmFE/zwG7DW9SoiLuysLULv1oCg8X0cIvc9eTrxyq/Q7BQHGO+j6Ajx5LZQ8fs9yPCNbQTpSs2k9dZ28OmU9Hz1hAQA9JCrJO9LL5bxe12e8BZh9PPc+OL09Qc08QdGkvNWMNbwZKYs8FbWbOqCd3Tz7zo+7MXGHvGW4bjsq0/87tzI6PK4AhDySRJi8Z1Z+PLoIGrvkAeO8PI4tPfHD8Lxoxp46KW1APUxCAzx2iCw9hVESvWmVJj1XXyk7ApQuvLEEU7u1QHI6KNYIPKcDFT0+39w7wmvIu3LjRLxEdgy7HhhKu2+npTuz2rK8xI4IvcOjmDy8WUm9Q2+0PPyBr7v7ele7zdymvC7oh7x8mqs7V/bgvAQyvjxo4oY82kp8vKkcdLyMTgG8VqwJPPuypzxR5OE8HwM6PJ9TBr1Pd8q8fnALPbawkrqSYAA9cX2FPMjmD72B3aI8pWUFvaQRzbwd4Hm8UeThPKFsZTw4NqY8yUxPPIMSajyWnJ+5PZWFu8wNHzvOj8Y747eLvM9eTrvVcE28y28PO0Tx2zzkhpO7teGKvNVwTTyiO2089bwQPfhFED1E8Vu8E0iEvMQJWDyB3SI9UyOKvATJ9bv+tvY8YPonPJWAN7wl+dA7HLKKPVXBmbz0mdA89m8wvMXY37wq0/87AcUmPe6HUTzWdyW8HUlCuxZoO7rUafW8H+fRu5AF8LsDf5474MXDPLYP+rp0zrS4JCrJvNgxnTwBjda78vvAO40BoTpLVxO9XY0QveJj07oVMOu8YN6/vE31ojxMJhs6XyugvOzpQT0S9Ms6juwQPJa4h7zYMR29tBIDPUVFFDyIVeE8NN4evAC+TrxDIlQ8WZTwPC0ZgLyLR6k71KFFPQmRnTxdcag7kXWQPMSOCDwo1gi9bQmWPJ0wxjxn95a86cmKPKI7bbyADps88CXhPMan5zs6a+057odRPItHqbzZTQU9IdkZvfVoWDtCvJS8nZkOvbD9+jvm18K7AeGOvImpGb3SAza9594aPZCKILxzenw8zSkHO55oFjty48Q7MrtePNE0rjpU8hG9YrSfvBL0SzuiO207G8eau2wCPjwdgZI84mNTPdocjbsAvs48CkS9PMA2gTzFELC8jtAoPO9W2bwl+VC8vvfYvEG1PLzlcYO8fgdDObuKQbtz/6w7hVGSvArb9DyX5na81KHFO2Dev7zxlQE9kiiwu+RVG7xWkCE9lUjnPOGwM7wLqnw7z3o2O+pLMjuvZsO7brw1vXi9c7xBTPQ8T0ZSvGmVpryCQ+K7zvgOvNeTDT1g+ic8IO6pvP3n7jt11Yy8eS2UPBFdlDwqPMg8onM9PGw6jryZQQc6Ozp1vQtLlTw0We47wzpQvI0diTwcliK95m76Ol7XZzuh1S26PAl9OyjWCD0FAUa8ZG6XPLZjMr32i5g7SYGzOvyBL7xvw428/jsnO031ojynA5W8GvgSvYClUjx0gVS8qiPMO/gpKLwMGh28CMKVvHJMjbzrsfE8rRUUvfaLGDwCsJa7kdT3vEiWQ7xVWNE8qaGkO9Q4/bym56w6Tj/6PKz5K7w68B09Wf04vTJADzzeJzQ7sJ4TvLXFIj2Y7c48Ozr1PMG4qDyM5Tg9" + } + ], + "model": "text-embedding-3-small", + "usage": { + "prompt_tokens": 4, + "total_tokens": 4 + } + } + headers: + Date: "Wed, 15 Jan 2025 11:25:55 GMT" + Content-Type: application/json + access-control-allow-origin: '*' + access-control-expose-headers: X-Request-ID + openai-model: text-embedding-3-small + openai-organization: test_openai_org_id + openai-processing-ms: "72" + openai-version: 2020-10-01 + strict-transport-security: max-age=31536000; includeSubDomains; preload + via: envoy-router-6546796fb4-hp5kf + x-envoy-upstream-service-time: "56" + x-ratelimit-limit-requests: "3000" + x-ratelimit-limit-tokens: "1000000" + x-ratelimit-remaining-requests: "2999" + x-ratelimit-remaining-tokens: "999994" + x-ratelimit-reset-requests: 20ms + x-ratelimit-reset-tokens: 0s + x-request-id: req_b8c83a88703f7772c7b23dc39f18f7e8 + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 90258201890a6910-FRA +uuid: dce3d5d1-803c-48f5-97cb-537b7aa4ac63 +persistent: true +insertionIndex: 32 +--- +id: 74958f37-7bf6-4130-bc0a-dcef6236e896 +name: embeddings +request: + url: /embeddings + method: POST + bodyPatterns: + - equalToJson: |- + { + "input" : [ "South Atlantic Ocean." ], + "model" : "not-a-model", + "encoding_format" : "base64" + } + ignoreArrayOrder: false + ignoreExtraElements: false +response: + status: 404 + body: | + { + "error": { + "message": "The model `not-a-model` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } + } + headers: + Date: "Wed, 15 Jan 2025 11:25:56 GMT" + Content-Type: application/json; charset=utf-8 + vary: Origin + x-request-id: req_a973c97036d0f21f3598a32bf29f9d6d + strict-transport-security: max-age=31536000; includeSubDomains; preload + CF-Cache-Status: DYNAMIC + Set-Cookie: test_set_cookie + X-Content-Type-Options: nosniff + Server: cloudflare + CF-RAY: 90258206bf14dbbf-FRA +uuid: 74958f37-7bf6-4130-bc0a-dcef6236e896 +persistent: true +insertionIndex: 51 diff --git a/settings.gradle.kts b/settings.gradle.kts index edd8dff8..9a9eb480 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,7 +16,7 @@ include("agent:entrypoint") include("agentextension") include("bootstrap") include("custom") -include("instrumentation") +include("instrumentation:openai-client-instrumentation") include("inferred-spans") include("resources") include("smoke-tests") diff --git a/smoke-tests/test-app/build.gradle.kts b/smoke-tests/test-app/build.gradle.kts index 2ca6df58..eb2b8be5 100644 --- a/smoke-tests/test-app/build.gradle.kts +++ b/smoke-tests/test-app/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.github.johnrengelman.shadow") + id("com.gradleup.shadow") id("elastic-otel.java-conventions") alias(catalog.plugins.jib) alias(catalog.plugins.taskinfo)