Skip to content

Add initial instrumentation of OpenAI client #14221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .fossa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,12 @@ targets:
- type: gradle
path: ./
target: ':instrumentation:okhttp:okhttp-3.0:library'
- type: gradle
path: ./
target: ':instrumentation:openai:openai-java-1.1:javaagent'
- type: gradle
path: ./
target: ':instrumentation:openai:openai-java-1.1:library'
- type: gradle
path: ./
target: ':instrumentation:opensearch:opensearch-rest-1.0:javaagent'
Expand Down
2 changes: 2 additions & 0 deletions buildscripts/checkstyle.xml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@
<property name="arrayInitIndent" value="2"/>
</module>
-->
<!-- handled by error prone
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="0"/>
Expand All @@ -262,6 +263,7 @@
PARAMETER_DEF, VARIABLE_DEF, METHOD_DEF, PATTERN_VARIABLE_DEF, RECORD_DEF,
RECORD_COMPONENT_DEF"/>
</module>
-->
<module name="OverloadMethodsDeclarationOrder"/>
<!-- there are only a few violations of this, and they all appear to be for good reasons
<module name="VariableDeclarationUsageDistance"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
}

dependencies {
compileOnly("org.scala-lang:scala-library:2.11.12")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IntelliJ was having trouble syncing the project without this. It seemed harmless so hopefully it's ok.

testCompileOnly("org.scala-lang:scala-library:2.11.12")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
import io.opentelemetry.api.common.Value;
import io.opentelemetry.api.trace.SpanContext;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.instrumentation.awssdk.v2_2.recording.RecordingExtension;
import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension;
import io.opentelemetry.instrumentation.testing.recording.RecordingExtension;
import io.opentelemetry.semconv.incubating.GenAiIncubatingAttributes;
import java.net.URI;
import java.util.ArrayList;
Expand Down
5 changes: 5 additions & 0 deletions instrumentation/openai/openai-java-1.1/javaagent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Settings for the OpenAI instrumentation

| System property | Type | Default | Description |
|------------------------------------------------------|---------|---------|------------------------------------------|
| `otel.instrumentation.genai.capture-message-content` | Boolean | `false` | record content of user and LLM messages. |
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
plugins {
id("otel.javaagent-instrumentation")
}

muzzle {
pass {
group.set("com.openai")
module.set("openai-java")
versions.set("[1.1.0,)")
// TODO: assertInverse after completing instrumentation
}
}

dependencies {
implementation(project(":instrumentation:openai:openai-java-1.1:library"))

testInstrumentation(project(":instrumentation:okhttp:okhttp-3.0:javaagent"))

library("com.openai:openai-java:1.1.0")

testImplementation(project(":instrumentation:openai:openai-java-1.1:testing"))
}

tasks {
withType<Test>().configureEach {
systemProperty("testLatestDeps", findProperty("testLatestDeps") as Boolean)
// TODO run tests both with and without genai message capture
systemProperty("otel.instrumentation.genai.capture-message-content", "true")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.instrumentation.openai.v1_1;

import static io.opentelemetry.javaagent.instrumentation.openai.v1_1.OpenAiSingletons.TELEMETRY;
import static net.bytebuddy.matcher.ElementMatchers.named;
import static net.bytebuddy.matcher.ElementMatchers.returns;

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 OpenAiClientInstrumentation implements TypeInstrumentation {
@Override
public ElementMatcher<TypeDescription> typeMatcher() {
return named("com.openai.client.okhttp.OpenAIOkHttpClient$Builder");
}

@Override
public void transform(TypeTransformer transformer) {
transformer.applyAdviceToMethod(
named("build").and(returns(named("com.openai.client.OpenAIClient"))),
OpenAiClientInstrumentation.class.getName() + "$BuildAdvice");
}

@SuppressWarnings("unused")
public static class BuildAdvice {
@Advice.OnMethodExit(suppress = Throwable.class)
@Advice.AssignReturned.ToReturned
public static OpenAIClient onExit(@Advice.Return OpenAIClient client) {
return TELEMETRY.wrap(client);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.instrumentation.openai.v1_1;

import static io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers.hasClassesNamed;
import static java.util.Collections.singletonList;

import com.google.auto.service.AutoService;
import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule;
import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation;
import java.util.List;
import net.bytebuddy.matcher.ElementMatcher;

@AutoService(InstrumentationModule.class)
public class OpenAiInstrumentationModule extends InstrumentationModule {
public OpenAiInstrumentationModule() {
super("openai-java", "openai-java-1.1", "openai");
}

@Override
public ElementMatcher.Junction<ClassLoader> classLoaderMatcher() {
return hasClassesNamed("com.openai.client.OpenAIClient");
}

@Override
public List<TypeInstrumentation> typeInstrumentations() {
return singletonList(new OpenAiClientInstrumentation());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.instrumentation.openai.v1_1;

import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.instrumentation.openai.v1_1.OpenAITelemetry;
import io.opentelemetry.javaagent.bootstrap.internal.AgentInstrumentationConfig;

public final class OpenAiSingletons {
public static final OpenAITelemetry TELEMETRY =
OpenAITelemetry.builder(GlobalOpenTelemetry.get())
.setCaptureMessageContent(
AgentInstrumentationConfig.get()
.getBoolean("otel.instrumentation.genai.capture-message-content", false))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should document this similarly to

| `otel.instrumentation.genai.capture-message-content` | Boolean | `false` | v2 only, record content of user and LLM messages when using Bedrock. |
An alternative would be to document in on the opentelemetry.io website.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, added a readme

.build();

private OpenAiSingletons() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.instrumentation.openai.v1_1;

import com.openai.client.OpenAIClient;
import io.opentelemetry.instrumentation.openai.v1_1.AbstractChatTest;
import io.opentelemetry.instrumentation.testing.junit.AgentInstrumentationExtension;
import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension;
import io.opentelemetry.sdk.testing.assertj.SpanDataAssert;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import org.junit.jupiter.api.extension.RegisterExtension;

class ChatTest extends AbstractChatTest {

@RegisterExtension
private static final AgentInstrumentationExtension testing =
AgentInstrumentationExtension.create();

@Override
protected InstrumentationExtension getTesting() {
return testing;
}

@Override
protected OpenAIClient wrap(OpenAIClient client) {
return client;
}

@Override
protected final List<Consumer<SpanDataAssert>> maybeWithTransportSpan(
Consumer<SpanDataAssert> span) {
List<Consumer<SpanDataAssert>> result = new ArrayList<>();
result.add(span);
// Do a very simple assertion since the telemetry is not part of this library.
result.add(s -> s.hasName("POST"));
return result;
}
}
16 changes: 16 additions & 0 deletions instrumentation/openai/openai-java-1.1/library/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
id("otel.library-instrumentation")
id("otel.nullaway-conventions")
}

dependencies {
library("com.openai:openai-java:1.1.0")

testImplementation(project(":instrumentation:openai:openai-java-1.1:testing"))
}

tasks {
withType<Test>().configureEach {
systemProperty("testLatestDeps", findProperty("testLatestDeps") as Boolean)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.instrumentation.openai.v1_1;

import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;

import com.openai.models.chat.completions.ChatCompletion;
import com.openai.models.chat.completions.ChatCompletionCreateParams;
import com.openai.models.completions.CompletionUsage;
import io.opentelemetry.instrumentation.api.incubator.semconv.genai.GenAiAttributesGetter;
import java.util.List;
import java.util.stream.Collectors;
import org.jetbrains.annotations.Nullable;

enum ChatAttributesGetter
implements GenAiAttributesGetter<ChatCompletionCreateParams, ChatCompletion> {
INSTANCE;

@Override
public String getOperationName(ChatCompletionCreateParams request) {
return GenAiAttributes.GenAiOperationNameIncubatingValues.CHAT;
}

@Override
public String getSystem(ChatCompletionCreateParams request) {
return GenAiAttributes.GenAiSystemIncubatingValues.OPENAI;
}

@Override
public String getRequestModel(ChatCompletionCreateParams request) {
return request.model().asString();
}

@Nullable
@Override
public Long getRequestSeed(ChatCompletionCreateParams request) {
return request.seed().orElse(null);
}

@Nullable
@Override
public List<String> getRequestEncodingFormats(ChatCompletionCreateParams request) {
return null;
}

@Nullable
@Override
public Double getRequestFrequencyPenalty(ChatCompletionCreateParams request) {
return request.frequencyPenalty().orElse(null);
}

@Nullable
@Override
public Long getRequestMaxTokens(ChatCompletionCreateParams request) {
return request.maxCompletionTokens().orElse(null);
}

@Nullable
@Override
public Double getRequestPresencePenalty(ChatCompletionCreateParams request) {
return request.presencePenalty().orElse(null);
}

@Nullable
@Override
public List<String> getRequestStopSequences(ChatCompletionCreateParams request) {
return request
.stop()
.map(
s -> {
if (s.isString()) {
return singletonList(s.asString());
}
if (s.isStrings()) {
return s.asStrings();
}
return null;
})
.orElse(null);
}

@Nullable
@Override
public Double getRequestTemperature(ChatCompletionCreateParams request) {
return request.temperature().orElse(null);
}

@Nullable
@Override
public Double getRequestTopK(ChatCompletionCreateParams request) {
return null;
}

@Nullable
@Override
public Double getRequestTopP(ChatCompletionCreateParams request) {
return request.topP().orElse(null);
}

@Override
public List<String> getResponseFinishReasons(
ChatCompletionCreateParams request, @Nullable ChatCompletion response) {
if (response == null) {
return emptyList();
}
return response.choices().stream()
.map(choice -> choice.finishReason().asString())
.collect(Collectors.toList());
}

@Override
@Nullable
public String getResponseId(
ChatCompletionCreateParams request, @Nullable ChatCompletion response) {
if (response == null) {
return null;
}
return response.id();
}

@Override
@Nullable
public String getResponseModel(
ChatCompletionCreateParams request, @Nullable ChatCompletion response) {
if (response == null) {
return null;
}
return response.model();
}

@Override
@Nullable
public Long getUsageInputTokens(
ChatCompletionCreateParams request, @Nullable ChatCompletion response) {
if (response == null) {
return null;
}
return response.usage().map(CompletionUsage::promptTokens).orElse(null);
}

@Override
@Nullable
public Long getUsageOutputTokens(
ChatCompletionCreateParams request, @Nullable ChatCompletion response) {
if (response == null) {
return null;
}
return response.usage().map(CompletionUsage::completionTokens).orElse(null);
}
}
Loading
Loading