diff --git a/AGENTS.md b/AGENTS.md
index e09dff0d473..b4431ea3010 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,6 +34,7 @@ docs/ Developer documentation (see below)
| Contributing & PR guidelines | [CONTRIBUTING.md](CONTRIBUTING.md) |
| How instrumentations work | [docs/how_instrumentations_work.md](docs/how_instrumentations_work.md) |
| Adding a new instrumentation | [docs/add_new_instrumentation.md](docs/add_new_instrumentation.md) |
+| Adding a new LLM instrumentation | [docs/add_new_llm_instrumentation.md](docs/add_new_llm_instrumentation.md) |
| Adding a new configuration | [docs/add_new_configurations.md](docs/add_new_configurations.md) |
| Testing guide (6 test types) | [docs/how_to_test.md](docs/how_to_test.md) |
| Working with Gradle | [docs/how_to_work_with_gradle.md](docs/how_to_work_with_gradle.md) |
diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/llm/LlmObsHandle.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/llm/LlmObsHandle.java
new file mode 100644
index 00000000000..d3dd3b543b5
--- /dev/null
+++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/llm/LlmObsHandle.java
@@ -0,0 +1,191 @@
+package datadog.trace.bootstrap.instrumentation.llm;
+
+import datadog.trace.api.llmobs.LLMObs;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Lifecycle handle for a single LLM operation. Drives JFR profiling events and LLMObs spans
+ * independently — either backend may be absent when its product is not configured.
+ *
+ *
Sync usage:
+ *
+ *
+ * LlmObsHandle handle = INTEGRATION.startLlm(modelId);
+ * handle.withInput(messages);
+ * // ... LLM call ...
+ * handle.withOutput(response).withTokenMetrics(in, out).finish();
+ *
+ *
+ * Async / streaming usage — the JFR event is committed immediately as a correlation marker while
+ * the LLMObs span stays open until the stream completes:
+ *
+ *
+ * LlmObsHandle handle = INTEGRATION.startLlm(modelId).withInput(messages).async();
+ * // hand handle to stream wrapper ...
+ * handle.withOutput(accumulated).withTokenMetrics(in, out).finish();
+ *
+ *
+ * Extension contract: subclasses implement {@link #onAsync()} and {@link #doFinish()} only. All
+ * state accumulation and lifecycle enforcement is managed by this class.
+ */
+public abstract class LlmObsHandle {
+
+ // finish() may be called from a different thread after async() hands off the handle
+ private final AtomicBoolean done = new AtomicBoolean();
+ private volatile boolean asyncMode;
+
+ private List inputMessages;
+ private List outputMessages;
+ private String inputData;
+ private String outputData;
+ private boolean hasError;
+ private Throwable thrown;
+ private Integer inputTokens;
+ private Integer outputTokens;
+
+ /**
+ * Switches to async mode. Triggers {@link #onAsync()} exactly once, then returns. The LLMObs span
+ * stays open until {@link #finish()} is called from the stream completion thread. Calls after
+ * {@link #finish()} are silently ignored.
+ *
+ * @return this handle, for chaining
+ */
+ public final LlmObsHandle async() {
+ if (!done.get()) {
+ asyncMode = true;
+ onAsync();
+ }
+ return this;
+ }
+
+ /**
+ * Sets structured input messages for LLM and embedding spans.
+ *
+ * @return this handle, for chaining
+ */
+ public final LlmObsHandle withInput(List messages) {
+ inputMessages = messages;
+ return this;
+ }
+
+ /**
+ * Sets plain-text input for workflow and tool spans.
+ *
+ * @return this handle, for chaining
+ */
+ public final LlmObsHandle withInput(String data) {
+ inputData = data;
+ return this;
+ }
+
+ /**
+ * Sets structured output messages.
+ *
+ * @return this handle, for chaining
+ */
+ public final LlmObsHandle withOutput(List messages) {
+ outputMessages = messages;
+ return this;
+ }
+
+ /**
+ * Sets plain-text output.
+ *
+ * @return this handle, for chaining
+ */
+ public final LlmObsHandle withOutput(String data) {
+ outputData = data;
+ return this;
+ }
+
+ /**
+ * Records token usage. Either value may be {@code null} when the model does not report it.
+ *
+ * @return this handle, for chaining
+ */
+ public final LlmObsHandle withTokenMetrics(Integer in, Integer out) {
+ inputTokens = in;
+ outputTokens = out;
+ return this;
+ }
+
+ /**
+ * Marks the operation as failed and attaches the throwable.
+ *
+ * @return this handle, for chaining
+ */
+ public final LlmObsHandle withError(Throwable t) {
+ hasError = true;
+ thrown = t;
+ return this;
+ }
+
+ /**
+ * Completes the operation. Calls {@link #doFinish()} exactly once regardless of how many times
+ * this method is invoked. Thread-safe: safe to call from any thread after {@link #async()}.
+ */
+ public final void finish() {
+ if (done.compareAndSet(false, true)) {
+ doFinish();
+ }
+ }
+
+ /**
+ * Called at most once when {@link #async()} first transitions to async mode, before {@link
+ * #doFinish()}. Override to commit a JFR event early as a correlation marker.
+ */
+ protected void onAsync() {}
+
+ /**
+ * Called exactly once by {@link #finish()}. Access accumulated state via the protected accessors
+ * ({@link #isAsync()}, {@link #inputMessages()}, etc.).
+ */
+ protected abstract void doFinish();
+
+ // --- Accessors for subclasses ---
+
+ /** Whether {@link #async()} was called on this handle. */
+ protected final boolean isAsync() {
+ return asyncMode;
+ }
+
+ protected final List inputMessages() {
+ return inputMessages;
+ }
+
+ protected final List outputMessages() {
+ return outputMessages;
+ }
+
+ protected final String inputData() {
+ return inputData;
+ }
+
+ protected final String outputData() {
+ return outputData;
+ }
+
+ protected final boolean hasError() {
+ return hasError;
+ }
+
+ protected final Throwable thrown() {
+ return thrown;
+ }
+
+ protected final Integer inputTokens() {
+ return inputTokens;
+ }
+
+ protected final Integer outputTokens() {
+ return outputTokens;
+ }
+
+ /** No-op handle returned when all backends are disabled. */
+ public static final LlmObsHandle NOOP =
+ new LlmObsHandle() {
+ @Override
+ protected void doFinish() {}
+ };
+}
diff --git a/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/AiServiceEvent.java b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/AiServiceEvent.java
new file mode 100644
index 00000000000..6cb34d6c5ec
--- /dev/null
+++ b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/AiServiceEvent.java
@@ -0,0 +1,40 @@
+package datadog.trace.bootstrap.instrumentation.jfr.llm;
+
+import jdk.jfr.Category;
+import jdk.jfr.Description;
+import jdk.jfr.Event;
+import jdk.jfr.Label;
+import jdk.jfr.Name;
+import jdk.jfr.StackTrace;
+
+@Name("datadog.AiService")
+@Label("AI Service")
+@Description("AI service method invocation")
+@Category({"Datadog", "LLM"})
+@StackTrace(false)
+@LLMOperation
+public class AiServiceEvent extends Event {
+
+ @Label("Service Type")
+ private final String serviceType;
+
+ @Label("Method Name")
+ private final String methodName;
+
+ @Label("Trace ID")
+ private String traceId;
+
+ @Label("Span ID")
+ private String spanId;
+
+ public AiServiceEvent(String serviceType, String methodName) {
+ this.serviceType = serviceType;
+ this.methodName = methodName;
+ begin();
+ }
+
+ public void setSpanContext(String traceId, String spanId) {
+ this.traceId = traceId;
+ this.spanId = spanId;
+ }
+}
diff --git a/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/ChatModelEvent.java b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/ChatModelEvent.java
new file mode 100644
index 00000000000..b58a0721bb8
--- /dev/null
+++ b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/ChatModelEvent.java
@@ -0,0 +1,36 @@
+package datadog.trace.bootstrap.instrumentation.jfr.llm;
+
+import jdk.jfr.Category;
+import jdk.jfr.Description;
+import jdk.jfr.Event;
+import jdk.jfr.Label;
+import jdk.jfr.Name;
+import jdk.jfr.StackTrace;
+
+@Name("datadog.ChatModel")
+@Label("Chat Model")
+@Description("LLM chat model invocation")
+@Category({"Datadog", "LLM"})
+@StackTrace(false)
+@LLMOperation
+public class ChatModelEvent extends Event {
+
+ @Label("Model Id")
+ private final String modelId;
+
+ @Label("Trace ID")
+ private String traceId;
+
+ @Label("Span ID")
+ private String spanId;
+
+ public ChatModelEvent(String modelId) {
+ this.modelId = modelId;
+ begin();
+ }
+
+ public void setSpanContext(String traceId, String spanId) {
+ this.traceId = traceId;
+ this.spanId = spanId;
+ }
+}
diff --git a/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/LLMOperation.java b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/LLMOperation.java
new file mode 100644
index 00000000000..d673cbcafd8
--- /dev/null
+++ b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/LLMOperation.java
@@ -0,0 +1,16 @@
+package datadog.trace.bootstrap.instrumentation.jfr.llm;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import jdk.jfr.Description;
+import jdk.jfr.Label;
+import jdk.jfr.MetadataDefinition;
+
+@MetadataDefinition
+@Label("LLM Operation")
+@Description("Marks an event as an LLM operation")
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface LLMOperation {}
diff --git a/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/ToolExecutorEvent.java b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/ToolExecutorEvent.java
new file mode 100644
index 00000000000..4f709dc80c5
--- /dev/null
+++ b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/ToolExecutorEvent.java
@@ -0,0 +1,36 @@
+package datadog.trace.bootstrap.instrumentation.jfr.llm;
+
+import jdk.jfr.Category;
+import jdk.jfr.Description;
+import jdk.jfr.Event;
+import jdk.jfr.Label;
+import jdk.jfr.Name;
+import jdk.jfr.StackTrace;
+
+@Name("datadog.ToolExecutor")
+@Label("Tool Executor")
+@Description("LLM tool invocation")
+@Category({"Datadog", "LLM"})
+@StackTrace(false)
+@LLMOperation
+public class ToolExecutorEvent extends Event {
+
+ @Label("Tool Name")
+ private final String toolName;
+
+ @Label("Trace ID")
+ private String traceId;
+
+ @Label("Span ID")
+ private String spanId;
+
+ public ToolExecutorEvent(String toolName) {
+ this.toolName = toolName;
+ begin();
+ }
+
+ public void setSpanContext(String traceId, String spanId) {
+ this.traceId = traceId;
+ this.spanId = spanId;
+ }
+}
diff --git a/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/llm/LlmCallHandle.java b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/llm/LlmCallHandle.java
new file mode 100644
index 00000000000..05be8a8668d
--- /dev/null
+++ b/dd-java-agent/agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/llm/LlmCallHandle.java
@@ -0,0 +1,91 @@
+package datadog.trace.bootstrap.instrumentation.llm;
+
+import datadog.trace.api.llmobs.LLMObsSpan;
+import datadog.trace.bootstrap.instrumentation.api.AgentScope;
+import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicBoolean;
+import jdk.jfr.Event;
+
+/**
+ * {@link LlmObsHandle} that drives a JFR {@link Event}, an {@link LLMObsSpan}, and an APM {@link
+ * AgentSpan} independently. Any field may be null when the corresponding backend is disabled.
+ */
+public final class LlmCallHandle extends LlmObsHandle {
+
+ private final Event jfrEvent;
+ private final LLMObsSpan llmObsSpan;
+ private final AgentScope agentScope;
+ // Ensures onAsync() and the sync path of doFinish() don't both close the scope.
+ private final AtomicBoolean scopeClosedOnEntry = new AtomicBoolean();
+
+ public LlmCallHandle(Event jfrEvent, LLMObsSpan llmObsSpan, AgentScope agentScope) {
+ this.jfrEvent = jfrEvent;
+ this.llmObsSpan = llmObsSpan;
+ this.agentScope = agentScope;
+ }
+
+ @Override
+ protected void onAsync() {
+ if (jfrEvent != null) {
+ jfrEvent.end();
+ if (jfrEvent.shouldCommit()) {
+ jfrEvent.commit();
+ }
+ }
+ // CAS prevents double-close if finish() already ran and won the done CAS before async() set
+ // asyncMode, causing doFinish() to read isAsync()==false and close the scope itself.
+ if (agentScope != null && scopeClosedOnEntry.compareAndSet(false, true)) {
+ agentScope.close();
+ }
+ }
+
+ @Override
+ protected void doFinish() {
+ if (jfrEvent != null && !isAsync()) {
+ jfrEvent.end();
+ if (jfrEvent.shouldCommit()) {
+ jfrEvent.commit();
+ }
+ }
+ try {
+ if (llmObsSpan != null) {
+ if (hasError()) {
+ llmObsSpan.setError(true);
+ if (thrown() != null) {
+ llmObsSpan.addThrowable(thrown());
+ }
+ }
+ if (inputTokens() != null) {
+ llmObsSpan.setMetric("input_tokens", inputTokens().intValue());
+ }
+ if (outputTokens() != null) {
+ llmObsSpan.setMetric("output_tokens", outputTokens().intValue());
+ }
+ if (inputMessages() != null || outputMessages() != null) {
+ llmObsSpan.annotateIO(
+ inputMessages() != null ? inputMessages() : Collections.emptyList(),
+ outputMessages() != null ? outputMessages() : Collections.emptyList());
+ } else if (inputData() != null || outputData() != null) {
+ llmObsSpan.annotateIO(inputData(), outputData());
+ }
+ llmObsSpan.finish();
+ }
+ } finally {
+ if (agentScope != null) {
+ AgentSpan span = agentScope.span();
+ if (hasError()) {
+ span.setError(true);
+ if (thrown() != null) {
+ span.addThrowable(thrown());
+ }
+ }
+ // Close scope before finishing span — standard dd-trace-java idiom.
+ if (!isAsync() && scopeClosedOnEntry.compareAndSet(false, true)) {
+ agentScope.close();
+ }
+ span.finish();
+ }
+ }
+ }
+}
diff --git a/dd-java-agent/agent-profiling/profiling-controller-jfr/src/main/resources/jfr/dd.jfp b/dd-java-agent/agent-profiling/profiling-controller-jfr/src/main/resources/jfr/dd.jfp
index f6a683361a9..559770c78c5 100644
--- a/dd-java-agent/agent-profiling/profiling-controller-jfr/src/main/resources/jfr/dd.jfp
+++ b/dd-java-agent/agent-profiling/profiling-controller-jfr/src/main/resources/jfr/dd.jfp
@@ -289,3 +289,6 @@ datadog.ProfilerSetting#enabled=true
datadog.ContextInterval#enabled=true
datadog.Timeline#enabled=true
datadog.Timeline#threshold=10 ms
+datadog.AiService#enabled=true
+datadog.ChatModel#enabled=true
+datadog.ToolExecutor#enabled=true
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/build.gradle b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/build.gradle
new file mode 100644
index 00000000000..0949828f677
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/build.gradle
@@ -0,0 +1,73 @@
+plugins {
+ id 'java-test-fixtures'
+}
+
+apply from: "$rootDir/gradle/java.gradle"
+
+def minVer = '1.0.0'
+
+testJvmConstraints {
+ minJavaVersion = JavaVersion.VERSION_17
+}
+
+// Must use Java 11+ to compile JFR-based code (jdk.jfr not in Java 8)
+tasks.named("compileJava", JavaCompile) {
+ configureCompiler(it, 17, JavaVersion.VERSION_1_8, "Must use Java 11+ to build JFR enabled code")
+}
+
+tasks.named("compileTestJava", JavaCompile) {
+ configureCompiler(it, 17, JavaVersion.VERSION_1_8, "Must use Java 11+ to build JFR enabled code")
+}
+
+tasks.named("compileTestFixturesJava", JavaCompile) {
+ configureCompiler(it, 17, JavaVersion.VERSION_1_8, "LLM TCK requires Java 11+ for JFR event classes")
+}
+
+muzzle {
+ pass {
+ group = "dev.langchain4j"
+ module = "langchain4j-core"
+ versions = "[$minVer,)"
+ }
+ pass {
+ group = "dev.langchain4j"
+ module = "langchain4j"
+ versions = "[$minVer,)"
+ }
+}
+
+addTestSuiteForDir('latestDepTest', 'test')
+
+dependencies {
+ testFixturesApi project(':dd-java-agent:agent-bootstrap')
+ testFixturesCompileOnly project(':internal-api')
+ testFixturesCompileOnly libs.bundles.junit5
+ testFixturesCompileOnly libs.bundles.mockito
+
+ compileOnly group: 'dev.langchain4j', name: 'langchain4j-core', version: minVer
+ testImplementation group: 'dev.langchain4j', name: 'langchain4j', version: minVer
+ testImplementation group: 'dev.langchain4j', name: 'langchain4j-ollama', version: '1.2.0'
+ latestDepTestImplementation group: 'dev.langchain4j', name: 'langchain4j', version: '+'
+ latestDepTestCompileOnly group: 'dev.langchain4j', name: 'langchain4j-ollama', version: '1.2.0'
+}
+
+tasks.register('runOllamaDemo', JavaExec) {
+ dependsOn testClasses, ':dd-java-agent:shadowJar'
+ classpath = sourceSets.test.runtimeClasspath
+ mainClass = 'datadog.trace.instrumentation.langchain4j.demo.OllamaLlmPipelineDemo'
+ // Pick the highest semver agent jar in build/libs, ignoring classifier jars.
+ def agentJar = fileTree("$rootDir/dd-java-agent/build/libs")
+ .include("dd-java-agent-*.jar")
+ .exclude("*-sources.jar", "*-javadoc.jar")
+ .files
+ .sort { f ->
+ def m = f.name =~ /dd-java-agent-(\d+)\.(\d+)\.(\d+)/
+ m ? (m[0][1] as int) * 1_000_000 + (m[0][2] as int) * 1_000 + (m[0][3] as int) : 0
+ }.last()
+ jvmArgs "-javaagent:${agentJar}",
+ "-Ddd.profiling.enabled=true",
+ "-Ddd.writer.type=LoggingWriter",
+ "-Ddd.profiling.start-force-first=true",
+ "-Ddd.profiling.upload.period=10",
+ "-Ddd.profiling.debug.dump_path=/tmp/llm-demo-profiles"
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/gradle.lockfile b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/gradle.lockfile
new file mode 100644
index 00000000000..53b644353de
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/gradle.lockfile
@@ -0,0 +1,145 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+cafe.cryptography:curve25519-elisabeth:0.1.0=latestDepTestRuntimeClasspath,testRuntimeClasspath
+cafe.cryptography:ed25519-elisabeth:0.1.0=latestDepTestRuntimeClasspath,testRuntimeClasspath
+ch.qos.logback:logback-classic:1.2.13=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+ch.qos.logback:logback-core:1.2.13=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.blogspot.mydailyjava:weak-lock-free:0.17=buildTimeInstrumentationPlugin,compileClasspath,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.datadoghq.okhttp3:okhttp:3.12.15=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.datadoghq.okio:okio:1.17.6=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.datadoghq:dd-instrument-java:0.0.4=buildTimeInstrumentationPlugin,compileClasspath,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.datadoghq:dd-javac-plugin-client:0.2.2=buildTimeInstrumentationPlugin,compileClasspath,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.datadoghq:java-dogstatsd-client:4.4.5=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.datadoghq:sketches-java:0.8.3=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-annotations:2.19.0=compileClasspath
+com.fasterxml.jackson.core:jackson-annotations:2.19.2=testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-annotations:2.21=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath
+com.fasterxml.jackson.core:jackson-core:2.19.0=compileClasspath
+com.fasterxml.jackson.core:jackson-core:2.19.2=testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-core:2.21.3=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath
+com.fasterxml.jackson.core:jackson-databind:2.19.0=compileClasspath
+com.fasterxml.jackson.core:jackson-databind:2.19.2=testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-databind:2.21.3=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath
+com.fasterxml.jackson:jackson-bom:2.19.0=compileClasspath
+com.fasterxml.jackson:jackson-bom:2.19.2=testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson:jackson-bom:2.21.3=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath
+com.github.javaparser:javaparser-core:3.25.6=codenarc
+com.github.jnr:jffi:1.3.14=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.github.jnr:jnr-a64asm:1.0.0=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.github.jnr:jnr-constants:0.10.4=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.github.jnr:jnr-enxio:0.32.19=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.github.jnr:jnr-ffi:2.2.18=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.github.jnr:jnr-posix:3.1.21=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.github.jnr:jnr-unixsocket:0.38.24=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.github.jnr:jnr-x86asm:1.0.2=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
+com.github.spotbugs:spotbugs:4.9.8=spotbugs
+com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
+com.google.auto.service:auto-service-annotations:1.1.1=annotationProcessor,compileClasspath,latestDepTestAnnotationProcessor,latestDepTestCompileClasspath,testAnnotationProcessor,testCompileClasspath
+com.google.auto.service:auto-service:1.1.1=annotationProcessor,latestDepTestAnnotationProcessor,testAnnotationProcessor
+com.google.auto:auto-common:1.2.1=annotationProcessor,latestDepTestAnnotationProcessor,testAnnotationProcessor
+com.google.code.findbugs:jsr305:3.0.2=annotationProcessor,compileClasspath,latestDepTestAnnotationProcessor,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,spotbugs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.code.gson:gson:2.13.2=spotbugs
+com.google.errorprone:error_prone_annotations:2.18.0=annotationProcessor,latestDepTestAnnotationProcessor,testAnnotationProcessor
+com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
+com.google.guava:failureaccess:1.0.1=annotationProcessor,latestDepTestAnnotationProcessor,testAnnotationProcessor
+com.google.guava:guava:20.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.google.guava:guava:32.0.1-jre=annotationProcessor,latestDepTestAnnotationProcessor,testAnnotationProcessor
+com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,latestDepTestAnnotationProcessor,testAnnotationProcessor
+com.google.j2objc:j2objc-annotations:2.8=annotationProcessor,latestDepTestAnnotationProcessor,testAnnotationProcessor
+com.google.re2j:re2j:1.7=latestDepTestRuntimeClasspath,testRuntimeClasspath
+com.squareup.moshi:moshi:1.11.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.squareup.okhttp3:logging-interceptor:3.12.12=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.squareup.okhttp3:okhttp:3.12.12=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.squareup.okio:okio:1.17.5=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.thoughtworks.qdox:qdox:1.12.1=codenarc
+commons-fileupload:commons-fileupload:1.5=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+commons-io:commons-io:2.11.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+commons-io:commons-io:2.20.0=spotbugs
+de.thetaphi:forbiddenapis:3.10=compileClasspath,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+dev.langchain4j:langchain4j-core:1.0.0=compileClasspath
+dev.langchain4j:langchain4j-core:1.16.1=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath
+dev.langchain4j:langchain4j-core:1.2.0=testCompileClasspath,testRuntimeClasspath
+dev.langchain4j:langchain4j-http-client-jdk:1.2.0=latestDepTestRuntimeClasspath,testRuntimeClasspath
+dev.langchain4j:langchain4j-http-client:1.2.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+dev.langchain4j:langchain4j-ollama:1.2.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+dev.langchain4j:langchain4j:1.0.0=testCompileClasspath,testRuntimeClasspath
+dev.langchain4j:langchain4j:1.16.1=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath
+io.leangen.geantyref:geantyref:1.3.16=latestDepTestRuntimeClasspath,testRuntimeClasspath
+io.sqreen:libsqreen:17.3.0=latestDepTestRuntimeClasspath,testRuntimeClasspath
+javax.servlet:javax.servlet-api:3.1.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+jaxen:jaxen:2.0.0=spotbugs
+junit:junit:4.13.2=latestDepTestRuntimeClasspath,testRuntimeClasspath
+net.bytebuddy:byte-buddy-agent:1.18.10=buildTimeInstrumentationPlugin,compileClasspath,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+net.bytebuddy:byte-buddy:1.18.10=buildTimeInstrumentationPlugin,compileClasspath,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+net.java.dev.jna:jna-platform:5.8.0=latestDepTestRuntimeClasspath,testRuntimeClasspath
+net.java.dev.jna:jna:5.8.0=latestDepTestRuntimeClasspath,testRuntimeClasspath
+net.sf.saxon:Saxon-HE:12.9=spotbugs
+org.apache.ant:ant-antlr:1.10.14=codenarc
+org.apache.ant:ant-junit:1.10.14=codenarc
+org.apache.bcel:bcel:6.11.0=spotbugs
+org.apache.commons:commons-lang3:3.19.0=spotbugs
+org.apache.commons:commons-text:1.14.0=spotbugs
+org.apache.logging.log4j:log4j-api:2.25.2=spotbugs
+org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
+org.apache.opennlp:opennlp-tools:2.5.4=testCompileClasspath,testRuntimeClasspath
+org.apache.opennlp:opennlp-tools:2.5.9=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath
+org.apiguardian:apiguardian-api:1.1.2=latestDepTestCompileClasspath,testCompileClasspath
+org.checkerframework:checker-qual:3.33.0=annotationProcessor,latestDepTestAnnotationProcessor,testAnnotationProcessor
+org.codehaus.groovy:groovy-ant:3.0.23=codenarc
+org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc
+org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc
+org.codehaus.groovy:groovy-json:3.0.23=codenarc
+org.codehaus.groovy:groovy-json:3.0.25=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.codehaus.groovy:groovy-templates:3.0.23=codenarc
+org.codehaus.groovy:groovy-xml:3.0.23=codenarc
+org.codehaus.groovy:groovy:3.0.23=codenarc
+org.codehaus.groovy:groovy:3.0.25=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.codenarc:CodeNarc:3.7.0=codenarc
+org.dom4j:dom4j:2.2.0=spotbugs
+org.gmetrics:GMetrics:2.1.0=codenarc
+org.hamcrest:hamcrest-core:1.3=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.hamcrest:hamcrest:3.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.jctools:jctools-core-jdk11:4.0.6=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.jctools:jctools-core:4.0.6=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.jspecify:jspecify:1.0.0=compileClasspath,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.junit.jupiter:junit-jupiter-api:5.14.1=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.junit.jupiter:junit-jupiter-engine:5.14.1=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.junit.jupiter:junit-jupiter-params:5.14.1=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.junit.jupiter:junit-jupiter:5.14.1=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.junit.platform:junit-platform-commons:1.14.1=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.junit.platform:junit-platform-engine:1.14.1=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.junit.platform:junit-platform-launcher:1.14.1=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.junit.platform:junit-platform-runner:1.14.1=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.junit.platform:junit-platform-suite-api:1.14.1=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.junit.platform:junit-platform-suite-commons:1.14.1=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.junit:junit-bom:5.14.0=spotbugs
+org.junit:junit-bom:5.14.1=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.mockito:mockito-core:4.4.0=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.objenesis:objenesis:3.3=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.opentest4j:opentest4j:1.3.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.ow2.asm:asm-analysis:9.7.1=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.ow2.asm:asm-analysis:9.9=spotbugs
+org.ow2.asm:asm-commons:9.9=spotbugs
+org.ow2.asm:asm-commons:9.9.1=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.ow2.asm:asm-tree:9.9=spotbugs
+org.ow2.asm:asm-tree:9.9.1=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.ow2.asm:asm-util:9.7.1=latestDepTestRuntimeClasspath,testRuntimeClasspath
+org.ow2.asm:asm-util:9.9=spotbugs
+org.ow2.asm:asm:9.9=spotbugs
+org.ow2.asm:asm:9.9.1=buildTimeInstrumentationPlugin,compileClasspath,latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.slf4j:jcl-over-slf4j:1.7.30=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.slf4j:jul-to-slf4j:1.7.30=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.slf4j:log4j-over-slf4j:1.7.30=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.slf4j:slf4j-api:1.7.30=buildTimeInstrumentationPlugin,muzzleBootstrap,muzzleTooling,runtimeClasspath
+org.slf4j:slf4j-api:2.0.17=compileClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
+org.slf4j:slf4j-api:2.0.18=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath
+org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
+org.snakeyaml:snakeyaml-engine:2.9=buildTimeInstrumentationPlugin,latestDepTestRuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath
+org.spockframework:spock-bom:2.4-groovy-3.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.spockframework:spock-core:2.4-groovy-3.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.tabletest:tabletest-junit:1.2.1=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.tabletest:tabletest-parser:1.2.0=latestDepTestCompileClasspath,latestDepTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.xmlresolver:xmlresolver:5.3.3=spotbugs
+empty=spotbugsPlugins
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/AiServicesInstrumentation.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/AiServicesInstrumentation.java
new file mode 100644
index 00000000000..a8ce0f73230
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/AiServicesInstrumentation.java
@@ -0,0 +1,74 @@
+package datadog.trace.instrumentation.langchain4j;
+
+import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.implementsInterface;
+import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.isMethod;
+import static net.bytebuddy.matcher.ElementMatchers.nameStartsWith;
+
+import datadog.trace.agent.tooling.Instrumenter;
+import datadog.trace.bootstrap.instrumentation.llm.LlmObsHandle;
+import java.lang.reflect.Method;
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.matcher.ElementMatcher;
+
+public class AiServicesInstrumentation
+ implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice {
+
+ @Override
+ public String hierarchyMarkerType() {
+ return "java.lang.reflect.InvocationHandler";
+ }
+
+ @Override
+ public ElementMatcher hierarchyMatcher() {
+ return implementsInterface(named("java.lang.reflect.InvocationHandler"))
+ .and(nameStartsWith("dev.langchain4j.service.DefaultAiServices$"));
+ }
+
+ @Override
+ public void methodAdvice(MethodTransformer transformer) {
+ transformer.applyAdvice(
+ isMethod()
+ // FQN used to disambiguate from
+ // datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named
+ .and(net.bytebuddy.matcher.ElementMatchers.named("invoke"))
+ .and(net.bytebuddy.matcher.ElementMatchers.takesArguments(3))
+ .and(
+ net.bytebuddy.matcher.ElementMatchers.takesArgument(
+ 1, named("java.lang.reflect.Method"))),
+ AiServicesInstrumentation.class.getName() + "$InvokeAdvice");
+ }
+
+ public static final class InvokeAdvice {
+ @Advice.OnMethodEnter(suppress = Throwable.class)
+ public static void enter(
+ @Advice.Argument(1) Method method,
+ @Advice.Argument(2) Object[] args,
+ @Advice.Local("handle") LlmObsHandle handle) {
+ if (method == null) return;
+ if (method.getDeclaringClass() == Object.class) return;
+ // Skip streaming/async return types that return before LLM work completes
+ Class> returnType = method.getReturnType();
+ if (returnType.getName().contains("TokenStream")
+ || returnType.getName().contains("CompletableFuture")) return;
+ handle =
+ LangChain4jLlmObsIntegration.INSTANCE.startWorkflow(
+ method.getDeclaringClass().getSimpleName(), method.getName());
+ if (args != null && args.length > 0 && args[0] != null) {
+ handle.withInput(args[0].toString());
+ }
+ }
+
+ @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
+ public static void exit(
+ @Advice.Local("handle") LlmObsHandle handle,
+ @Advice.Return Object result,
+ @Advice.Thrown Throwable err) {
+ if (handle == null) return;
+ if (result != null) handle.withOutput(result.toString());
+ if (err != null) handle.withError(err);
+ handle.finish();
+ }
+ }
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/ChatModelInstrumentation.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/ChatModelInstrumentation.java
new file mode 100644
index 00000000000..61c0a15ad96
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/ChatModelInstrumentation.java
@@ -0,0 +1,103 @@
+package datadog.trace.instrumentation.langchain4j;
+
+import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.implementsInterface;
+import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.isMethod;
+import static net.bytebuddy.matcher.ElementMatchers.takesArgument;
+
+import datadog.trace.agent.tooling.Instrumenter;
+import datadog.trace.api.llmobs.LLMObs;
+import datadog.trace.bootstrap.instrumentation.llm.LlmObsHandle;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.ToolExecutionResultMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.output.TokenUsage;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.matcher.ElementMatcher;
+
+public class ChatModelInstrumentation
+ implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice {
+
+ @Override
+ public String hierarchyMarkerType() {
+ return "dev.langchain4j.model.chat.ChatModel";
+ }
+
+ @Override
+ public ElementMatcher hierarchyMatcher() {
+ return implementsInterface(named("dev.langchain4j.model.chat.ChatModel"));
+ }
+
+ @Override
+ public void methodAdvice(MethodTransformer transformer) {
+ transformer.applyAdvice(
+ isMethod()
+ .and(net.bytebuddy.matcher.ElementMatchers.named("chat"))
+ .and(takesArgument(0, named("dev.langchain4j.model.chat.request.ChatRequest"))),
+ ChatModelInstrumentation.class.getName() + "$ChatAdvice");
+ }
+
+ public static final class ChatAdvice {
+ @Advice.OnMethodEnter(suppress = Throwable.class)
+ public static void enter(
+ @Advice.Argument(0) ChatRequest request, @Advice.Local("handle") LlmObsHandle handle) {
+ if (request == null) return;
+ String modelId = request.parameters() != null ? request.parameters().modelName() : null;
+ handle = LangChain4jLlmObsIntegration.INSTANCE.startLlm(modelId);
+ if (request.messages() != null && !request.messages().isEmpty()) {
+ List inputs = new ArrayList<>();
+ for (ChatMessage msg : request.messages()) {
+ switch (msg.type()) {
+ case SYSTEM:
+ inputs.add(LLMObs.LLMMessage.from("system", ((SystemMessage) msg).text()));
+ break;
+ case USER:
+ UserMessage um = (UserMessage) msg;
+ inputs.add(
+ LLMObs.LLMMessage.from(
+ "user", um.hasSingleText() ? um.singleText() : um.toString()));
+ break;
+ case AI:
+ inputs.add(LLMObs.LLMMessage.from("assistant", ((AiMessage) msg).text()));
+ break;
+ case TOOL_EXECUTION_RESULT:
+ inputs.add(LLMObs.LLMMessage.from("tool", ((ToolExecutionResultMessage) msg).text()));
+ break;
+ default:
+ inputs.add(LLMObs.LLMMessage.from("unknown", msg.toString()));
+ }
+ }
+ handle.withInput(inputs);
+ }
+ }
+
+ @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
+ public static void exit(
+ @Advice.Local("handle") LlmObsHandle handle,
+ @Advice.Return ChatResponse response,
+ @Advice.Thrown Throwable err) {
+ if (handle == null) return;
+ if (response != null) {
+ AiMessage aiMsg = response.aiMessage();
+ if (aiMsg != null) {
+ handle.withOutput(
+ Collections.singletonList(LLMObs.LLMMessage.from("assistant", aiMsg.text())));
+ }
+ TokenUsage usage = response.tokenUsage();
+ if (usage != null) {
+ handle.withTokenMetrics(usage.inputTokenCount(), usage.outputTokenCount());
+ }
+ }
+ if (err != null) handle.withError(err);
+ handle.finish();
+ }
+ }
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/LangChain4jLlmObsIntegration.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/LangChain4jLlmObsIntegration.java
new file mode 100644
index 00000000000..b5148cd65b1
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/LangChain4jLlmObsIntegration.java
@@ -0,0 +1,129 @@
+package datadog.trace.instrumentation.langchain4j;
+
+import datadog.trace.api.Config;
+import datadog.trace.api.llmobs.LLMObs;
+import datadog.trace.api.llmobs.LLMObsSpan;
+import datadog.trace.bootstrap.instrumentation.api.AgentScope;
+import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
+import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
+import datadog.trace.bootstrap.instrumentation.api.Tags;
+import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
+import datadog.trace.bootstrap.instrumentation.jfr.llm.AiServiceEvent;
+import datadog.trace.bootstrap.instrumentation.jfr.llm.ChatModelEvent;
+import datadog.trace.bootstrap.instrumentation.jfr.llm.ToolExecutorEvent;
+import datadog.trace.bootstrap.instrumentation.llm.LlmCallHandle;
+import datadog.trace.bootstrap.instrumentation.llm.LlmObsHandle;
+
+/** Creates {@link LlmObsHandle} instances for LangChain4j operation types. */
+public final class LangChain4jLlmObsIntegration {
+
+ public static final LangChain4jLlmObsIntegration INSTANCE = new LangChain4jLlmObsIntegration();
+
+ private static final String INSTRUMENTATION_NAME = "langchain4j";
+ private static final CharSequence OPERATION_CHAT_MODEL =
+ UTF8BytesString.create("langchain4j.chat_model.request");
+ private static final CharSequence OPERATION_AI_SERVICE =
+ UTF8BytesString.create("langchain4j.ai_service.request");
+ private static final CharSequence OPERATION_TOOL_EXECUTOR =
+ UTF8BytesString.create("langchain4j.tool_executor.request");
+
+ private LangChain4jLlmObsIntegration() {}
+
+ public LlmObsHandle startLlm(String modelId) {
+ ChatModelEvent jfrEvent = new ChatModelEvent(modelId);
+ boolean jfrActive = jfrEvent.isEnabled();
+ boolean obsActive = LLMObs.isEnabled();
+ AgentScope agentScope = startApmSpan(OPERATION_CHAT_MODEL, modelId, Tags.SPAN_KIND_CLIENT);
+ if (!jfrActive && !obsActive && agentScope == null) return LlmObsHandle.NOOP;
+ try {
+ LLMObsSpan obsSpan =
+ obsActive ? LLMObs.startLLMSpan(modelId, modelId, null, null, null) : null;
+ setJfrSpanContext(jfrEvent, jfrActive, obsSpan, agentScope);
+ return new LlmCallHandle(jfrActive ? jfrEvent : null, obsSpan, agentScope);
+ } catch (Throwable t) {
+ abortApmSpan(agentScope);
+ return LlmObsHandle.NOOP;
+ }
+ }
+
+ public LlmObsHandle startWorkflow(String serviceType, String methodName) {
+ AiServiceEvent jfrEvent = new AiServiceEvent(serviceType, methodName);
+ boolean jfrActive = jfrEvent.isEnabled();
+ boolean obsActive = LLMObs.isEnabled();
+ String resource = serviceType + "." + methodName;
+ AgentScope agentScope = startApmSpan(OPERATION_AI_SERVICE, resource, Tags.SPAN_KIND_INTERNAL);
+ if (!jfrActive && !obsActive && agentScope == null) return LlmObsHandle.NOOP;
+ try {
+ LLMObsSpan obsSpan = obsActive ? LLMObs.startWorkflowSpan(resource, null, null) : null;
+ setJfrSpanContext(jfrEvent, jfrActive, obsSpan, agentScope);
+ return new LlmCallHandle(jfrActive ? jfrEvent : null, obsSpan, agentScope);
+ } catch (Throwable t) {
+ abortApmSpan(agentScope);
+ return LlmObsHandle.NOOP;
+ }
+ }
+
+ public LlmObsHandle startTool(String toolName) {
+ ToolExecutorEvent jfrEvent = new ToolExecutorEvent(toolName);
+ boolean jfrActive = jfrEvent.isEnabled();
+ boolean obsActive = LLMObs.isEnabled();
+ AgentScope agentScope =
+ startApmSpan(OPERATION_TOOL_EXECUTOR, toolName, Tags.SPAN_KIND_INTERNAL);
+ if (!jfrActive && !obsActive && agentScope == null) return LlmObsHandle.NOOP;
+ try {
+ LLMObsSpan obsSpan = obsActive ? LLMObs.startToolSpan(toolName, null, null) : null;
+ setJfrSpanContext(jfrEvent, jfrActive, obsSpan, agentScope);
+ return new LlmCallHandle(jfrActive ? jfrEvent : null, obsSpan, agentScope);
+ } catch (Throwable t) {
+ abortApmSpan(agentScope);
+ return LlmObsHandle.NOOP;
+ }
+ }
+
+ private static AgentScope startApmSpan(
+ CharSequence operationName, String resourceName, String spanKind) {
+ if (!AgentTracer.isRegistered() || !Config.get().isTraceEnabled()) return null;
+ AgentSpan span = AgentTracer.startSpan(INSTRUMENTATION_NAME, operationName);
+ span.setTag(Tags.COMPONENT, INSTRUMENTATION_NAME);
+ span.setTag(Tags.SPAN_KIND, spanKind);
+ span.setResourceName(resourceName != null ? resourceName : "unknown");
+ return AgentTracer.activateSpan(span);
+ }
+
+ /** Correlates the JFR event with LLMObs span IDs, falling back to APM span IDs. */
+ private static void setJfrSpanContext(
+ jdk.jfr.Event jfrEvent, boolean jfrActive, LLMObsSpan obsSpan, AgentScope agentScope) {
+ if (!jfrActive) return;
+ if (obsSpan != null) {
+ // Prefer LLMObs span for correlation so JFR and LLMObs traces join.
+ if (jfrEvent instanceof AiServiceEvent) {
+ ((AiServiceEvent) jfrEvent)
+ .setSpanContext(obsSpan.getTraceId().toString(), Long.toHexString(obsSpan.getSpanId()));
+ } else if (jfrEvent instanceof ChatModelEvent) {
+ ((ChatModelEvent) jfrEvent)
+ .setSpanContext(obsSpan.getTraceId().toString(), Long.toHexString(obsSpan.getSpanId()));
+ } else if (jfrEvent instanceof ToolExecutorEvent) {
+ ((ToolExecutorEvent) jfrEvent)
+ .setSpanContext(obsSpan.getTraceId().toString(), Long.toHexString(obsSpan.getSpanId()));
+ }
+ } else if (agentScope != null) {
+ // LLMObs disabled — fall back to APM span so JFR events still carry trace context.
+ AgentSpan apmSpan = agentScope.span();
+ String traceId = apmSpan.getTraceId().toString();
+ String spanId = Long.toHexString(apmSpan.getSpanId());
+ if (jfrEvent instanceof AiServiceEvent) {
+ ((AiServiceEvent) jfrEvent).setSpanContext(traceId, spanId);
+ } else if (jfrEvent instanceof ChatModelEvent) {
+ ((ChatModelEvent) jfrEvent).setSpanContext(traceId, spanId);
+ } else if (jfrEvent instanceof ToolExecutorEvent) {
+ ((ToolExecutorEvent) jfrEvent).setSpanContext(traceId, spanId);
+ }
+ }
+ }
+
+ private static void abortApmSpan(AgentScope agentScope) {
+ if (agentScope == null) return;
+ agentScope.span().finish();
+ agentScope.close();
+ }
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/LangChain4jProfilingModule.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/LangChain4jProfilingModule.java
new file mode 100644
index 00000000000..7a450aab187
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/LangChain4jProfilingModule.java
@@ -0,0 +1,40 @@
+package datadog.trace.instrumentation.langchain4j;
+
+import static datadog.trace.agent.tooling.InstrumenterModule.TargetSystem.LLMOBS;
+import static datadog.trace.agent.tooling.InstrumenterModule.TargetSystem.PROFILING;
+import static datadog.trace.agent.tooling.InstrumenterModule.TargetSystem.TRACING;
+
+import com.google.auto.service.AutoService;
+import datadog.trace.agent.tooling.Instrumenter;
+import datadog.trace.agent.tooling.InstrumenterModule;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+
+@AutoService(InstrumenterModule.class)
+public class LangChain4jProfilingModule extends InstrumenterModule {
+
+ public LangChain4jProfilingModule() {
+ super("langchain4j");
+ }
+
+ @Override
+ public boolean isApplicable(Set enabledSystems) {
+ return enabledSystems.contains(TRACING)
+ || enabledSystems.contains(PROFILING)
+ || enabledSystems.contains(LLMOBS);
+ }
+
+ @Override
+ public String[] helperClassNames() {
+ return new String[] {packageName + ".LangChain4jLlmObsIntegration"};
+ }
+
+ @Override
+ public List typeInstrumentations() {
+ return Arrays.asList(
+ new ChatModelInstrumentation(),
+ new ToolExecutorInstrumentation(),
+ new AiServicesInstrumentation());
+ }
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/ToolExecutorInstrumentation.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/ToolExecutorInstrumentation.java
new file mode 100644
index 00000000000..6f5a36ff07d
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/main/java/datadog/trace/instrumentation/langchain4j/ToolExecutorInstrumentation.java
@@ -0,0 +1,57 @@
+package datadog.trace.instrumentation.langchain4j;
+
+import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.implementsInterface;
+import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.isMethod;
+import static net.bytebuddy.matcher.ElementMatchers.takesArgument;
+
+import datadog.trace.agent.tooling.Instrumenter;
+import datadog.trace.bootstrap.instrumentation.llm.LlmObsHandle;
+import dev.langchain4j.agent.tool.ToolExecutionRequest;
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.matcher.ElementMatcher;
+
+public class ToolExecutorInstrumentation
+ implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice {
+
+ @Override
+ public String hierarchyMarkerType() {
+ return "dev.langchain4j.service.tool.ToolExecutor";
+ }
+
+ @Override
+ public ElementMatcher hierarchyMatcher() {
+ return implementsInterface(named("dev.langchain4j.service.tool.ToolExecutor"));
+ }
+
+ @Override
+ public void methodAdvice(MethodTransformer transformer) {
+ transformer.applyAdvice(
+ isMethod()
+ .and(net.bytebuddy.matcher.ElementMatchers.named("execute"))
+ .and(takesArgument(0, named("dev.langchain4j.agent.tool.ToolExecutionRequest"))),
+ ToolExecutorInstrumentation.class.getName() + "$ExecuteAdvice");
+ }
+
+ public static final class ExecuteAdvice {
+ @Advice.OnMethodEnter(suppress = Throwable.class)
+ public static void enter(
+ @Advice.Argument(0) ToolExecutionRequest request,
+ @Advice.Local("handle") LlmObsHandle handle) {
+ if (request == null) return;
+ handle = LangChain4jLlmObsIntegration.INSTANCE.startTool(request.name());
+ }
+
+ @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
+ public static void exit(
+ @Advice.Local("handle") LlmObsHandle handle,
+ @Advice.Return String result,
+ @Advice.Thrown Throwable err) {
+ if (handle == null) return;
+ if (result != null) handle.withOutput(result);
+ if (err != null) handle.withError(err);
+ handle.finish();
+ }
+ }
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/LlmCallHandleTckTest.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/LlmCallHandleTckTest.java
new file mode 100644
index 00000000000..9e9e7861291
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/LlmCallHandleTckTest.java
@@ -0,0 +1,13 @@
+package datadog.trace.instrumentation.langchain4j;
+
+import datadog.trace.bootstrap.instrumentation.jfr.llm.ChatModelEvent;
+import datadog.trace.instrumentation.llm.tck.LlmObsHandleTck;
+import jdk.jfr.Event;
+
+class LlmCallHandleTckTest extends LlmObsHandleTck {
+
+ @Override
+ protected Event makeJfrEvent() {
+ return new ChatModelEvent("test-model");
+ }
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/LlmJfrEventTest.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/LlmJfrEventTest.java
new file mode 100644
index 00000000000..3359eabdd0a
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/LlmJfrEventTest.java
@@ -0,0 +1,94 @@
+package datadog.trace.instrumentation.langchain4j;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import datadog.trace.bootstrap.instrumentation.jfr.llm.AiServiceEvent;
+import datadog.trace.bootstrap.instrumentation.jfr.llm.ChatModelEvent;
+import datadog.trace.bootstrap.instrumentation.jfr.llm.LLMOperation;
+import datadog.trace.bootstrap.instrumentation.jfr.llm.ToolExecutorEvent;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+import jdk.jfr.Recording;
+import jdk.jfr.consumer.RecordedEvent;
+import jdk.jfr.consumer.RecordingFile;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class LlmJfrEventTest {
+
+ @TempDir Path tempDir;
+
+ @Test
+ void allEventClassesAreAnnotatedWithLLMOperation() {
+ assertTrue(ChatModelEvent.class.isAnnotationPresent(LLMOperation.class));
+ assertTrue(ToolExecutorEvent.class.isAnnotationPresent(LLMOperation.class));
+ assertTrue(AiServiceEvent.class.isAnnotationPresent(LLMOperation.class));
+ }
+
+ @Test
+ void chatModelEventIsEmittedToJfr() throws Exception {
+ Path dump = tempDir.resolve("jfr-chat.jfr");
+ try (Recording r = new Recording()) {
+ r.enable("datadog.ChatModel");
+ r.start();
+ ChatModelEvent event = new ChatModelEvent("gpt-4");
+ event.end();
+ assertTrue(event.shouldCommit(), "event should be committable");
+ if (event.shouldCommit()) event.commit();
+ r.stop();
+ r.dump(dump);
+ }
+ List events = readEvents(dump, "datadog.ChatModel");
+ assertEquals(1, events.size());
+ assertEquals("gpt-4", events.get(0).getString("modelId"));
+ }
+
+ @Test
+ void toolExecutorEventIsEmittedToJfr() throws Exception {
+ Path dump = tempDir.resolve("jfr-tool.jfr");
+ try (Recording r = new Recording()) {
+ r.enable("datadog.ToolExecutor");
+ r.start();
+ ToolExecutorEvent event = new ToolExecutorEvent("getWeather");
+ event.end();
+ assertTrue(event.shouldCommit(), "event should be committable");
+ if (event.shouldCommit()) event.commit();
+ r.stop();
+ r.dump(dump);
+ }
+ List events = readEvents(dump, "datadog.ToolExecutor");
+ assertEquals(1, events.size());
+ assertEquals("getWeather", events.get(0).getString("toolName"));
+ }
+
+ @Test
+ void aiServiceEventIsEmittedToJfr() throws Exception {
+ Path dump = tempDir.resolve("jfr-aiservice.jfr");
+ try (Recording r = new Recording()) {
+ r.enable("datadog.AiService");
+ r.start();
+ AiServiceEvent event = new AiServiceEvent("WeatherAssistant", "chat");
+ event.end();
+ assertTrue(event.shouldCommit(), "event should be committable");
+ if (event.shouldCommit()) event.commit();
+ r.stop();
+ r.dump(dump);
+ }
+ List events = readEvents(dump, "datadog.AiService");
+ assertEquals(1, events.size());
+ assertEquals("WeatherAssistant", events.get(0).getString("serviceType"));
+ assertEquals("chat", events.get(0).getString("methodName"));
+ }
+
+ private static List readEvents(Path path, String eventType) throws Exception {
+ try (RecordingFile rf = new RecordingFile(path)) {
+ List all = new java.util.ArrayList<>();
+ while (rf.hasMoreEvents()) all.add(rf.readEvent());
+ return all.stream()
+ .filter(e -> e.getEventType().getName().equals(eventType))
+ .collect(Collectors.toList());
+ }
+ }
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/demo/MockLlmPipelineTest.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/demo/MockLlmPipelineTest.java
new file mode 100644
index 00000000000..455bc8acaab
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/demo/MockLlmPipelineTest.java
@@ -0,0 +1,69 @@
+package datadog.trace.instrumentation.langchain4j.demo;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import dev.langchain4j.agent.tool.Tool;
+import dev.langchain4j.agent.tool.ToolExecutionRequest;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.memory.chat.MessageWindowChatMemory;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.service.AiServices;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+
+public class MockLlmPipelineTest {
+
+ interface WeatherAssistant {
+ String chat(String message);
+ }
+
+ static class MockWeatherTool {
+ @Tool("Get current weather for a location")
+ public String getWeather(String location) {
+ return "Sunny, 22°C in " + location;
+ }
+ }
+
+ static class TwoTurnMockModel implements ChatModel {
+ private final AtomicInteger calls = new AtomicInteger();
+
+ @Override
+ public ChatResponse chat(ChatRequest request) {
+ try {
+ Thread.sleep(30);
+ } catch (InterruptedException ignored) {
+ }
+ if (calls.getAndIncrement() == 0) {
+ return ChatResponse.builder()
+ .aiMessage(
+ AiMessage.from(
+ ToolExecutionRequest.builder()
+ .name("getWeather")
+ .arguments("{\"location\": \"Amsterdam\"}")
+ .build()))
+ .build();
+ }
+ return ChatResponse.builder()
+ .aiMessage(AiMessage.from("The weather in Amsterdam is Sunny, 22°C."))
+ .build();
+ }
+ }
+
+ // Smoke test: verifies the mock pipeline executes and returns a response.
+ // This test runs without the agent, so ByteBuddy advice is never applied.
+ // For JFR event-level verification see LlmJfrEventTest.
+ @Test
+ public void pipelineReturnsExpectedResponse() {
+ WeatherAssistant assistant =
+ AiServices.builder(WeatherAssistant.class)
+ .chatModel(new TwoTurnMockModel())
+ .chatMemory(MessageWindowChatMemory.withMaxMessages(10))
+ .tools(new MockWeatherTool())
+ .build();
+
+ String response = assistant.chat("What is the weather in Amsterdam?");
+ assertNotNull(response, "Expected a non-null response from the mock pipeline");
+ }
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/demo/OllamaLlmPipelineDemo.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/demo/OllamaLlmPipelineDemo.java
new file mode 100644
index 00000000000..c91d43f1644
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/test/java/datadog/trace/instrumentation/langchain4j/demo/OllamaLlmPipelineDemo.java
@@ -0,0 +1,65 @@
+package datadog.trace.instrumentation.langchain4j.demo;
+
+import dev.langchain4j.memory.chat.MessageWindowChatMemory;
+import dev.langchain4j.model.ollama.OllamaChatModel;
+import dev.langchain4j.service.AiServices;
+import java.time.Duration;
+
+/**
+ * Exercises LangChain4j instrumentation against a local Ollama server.
+ *
+ * Emits three JFR event types per pipeline turn:
+ *
+ *
+ * - {@code datadog.AiService} — AI service method invocation
+ *
- {@code datadog.ChatModel} — blocking LLM chat call
+ *
- {@code datadog.ToolExecutor} — tool execution (when tools are used)
+ *
+ *
+ * Run:
+ *
+ *
+ * ./gradlew :dd-java-agent:instrumentation:langchain4j:langchain4j-1.0:runOllamaDemo
+ *
+ *
+ * Prerequisites: {@code ollama serve && ollama pull llama3}
+ */
+public class OllamaLlmPipelineDemo {
+
+ interface Assistant {
+ String chat(String message);
+ }
+
+ public static void main(String[] args) {
+ OllamaChatModel model =
+ OllamaChatModel.builder()
+ .baseUrl("http://localhost:11434")
+ .modelName("llama3")
+ .timeout(Duration.ofMinutes(2))
+ .build();
+
+ Assistant assistant =
+ AiServices.builder(Assistant.class)
+ .chatModel(model)
+ .chatMemory(MessageWindowChatMemory.withMaxMessages(10))
+ .build();
+
+ String[] questions = {
+ "Explain Java garbage collection in one sentence.",
+ "What is a Java virtual thread?",
+ "Name one advantage of the G1 garbage collector."
+ };
+ for (String q : questions) {
+ System.out.println("Q: " + q);
+ System.out.println("A: " + assistant.chat(q));
+ }
+
+ // Hold the JVM alive so the profiler flushes at least two recording cycles.
+ System.out.println("Waiting 25 s for profiling data to flush...");
+ try {
+ Thread.sleep(25_000);
+ } catch (InterruptedException ignored) {
+ }
+ System.out.println("Done.");
+ }
+}
diff --git a/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/testFixtures/java/datadog/trace/instrumentation/llm/tck/LlmObsHandleTck.java b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/testFixtures/java/datadog/trace/instrumentation/llm/tck/LlmObsHandleTck.java
new file mode 100644
index 00000000000..27541b92f2a
--- /dev/null
+++ b/dd-java-agent/instrumentation/langchain4j/langchain4j-1.0/src/testFixtures/java/datadog/trace/instrumentation/llm/tck/LlmObsHandleTck.java
@@ -0,0 +1,180 @@
+package datadog.trace.instrumentation.llm.tck;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import datadog.trace.api.llmobs.LLMObs;
+import datadog.trace.api.llmobs.LLMObsSpan;
+import datadog.trace.bootstrap.instrumentation.api.AgentScope;
+import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
+import datadog.trace.bootstrap.instrumentation.llm.LlmCallHandle;
+import java.util.List;
+import jdk.jfr.Event;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+
+/**
+ * Technology compatibility kit for {@link LlmCallHandle}.
+ *
+ * Every LLM framework instrumentation that builds on the {@link
+ * datadog.trace.bootstrap.instrumentation.llm.LlmObsHandle} SPI must extend this class and
+ * implement {@link #makeJfrEvent()} to verify the handle lifecycle contract.
+ *
+ *
Integration-factory-level testing (verifying that {@code startLlm} / {@code startWorkflow} /
+ * {@code startTool} wire up the right spans end-to-end with real tracer/JFR/LLMObs
+ * infrastructure) is left as a follow-up TCK.
+ */
+public abstract class LlmObsHandleTck {
+
+ protected AgentScope scope;
+ protected AgentSpan span;
+ protected LLMObsSpan obsSpan;
+
+ @BeforeEach
+ void setUpMocks() {
+ span = mock(AgentSpan.class);
+ scope = mock(AgentScope.class);
+ when(scope.span()).thenReturn(span);
+ obsSpan = mock(LLMObsSpan.class);
+ }
+
+ /**
+ * Return the JFR event class specific to the integration under test, with {@code begin()} already
+ * called (as done by real advice).
+ */
+ protected abstract Event makeJfrEvent();
+
+ // --- handle factories ---
+
+ protected LlmCallHandle allBackends() {
+ return new LlmCallHandle(makeJfrEvent(), obsSpan, scope);
+ }
+
+ protected LlmCallHandle apmOnly() {
+ return new LlmCallHandle(null, null, scope);
+ }
+
+ protected LlmCallHandle obsOnly() {
+ return new LlmCallHandle(null, obsSpan, null);
+ }
+
+ // --- lifecycle ---
+
+ @Test
+ void finishIsIdempotent() {
+ LlmCallHandle handle = allBackends();
+ handle.finish();
+ handle.finish();
+ verify(span, times(1)).finish();
+ verify(obsSpan, times(1)).finish();
+ }
+
+ @Test
+ void scopeClosedBeforeSpanFinished() {
+ allBackends().finish();
+ InOrder order = inOrder(scope, span);
+ order.verify(scope).close();
+ order.verify(span).finish();
+ }
+
+ @Test
+ void scopeAlwaysClosedEvenWhenObsSpanThrows() {
+ doThrow(new RuntimeException("obs failure")).when(obsSpan).finish();
+ assertThrows(RuntimeException.class, () -> allBackends().finish());
+ verify(scope).close();
+ }
+
+ // --- error propagation ---
+
+ @Test
+ void errorWithThrowablePropagatedToBothBackends() {
+ RuntimeException err = new RuntimeException("boom");
+ allBackends().withError(err).finish();
+ verify(obsSpan).setError(true);
+ verify(obsSpan).addThrowable(err);
+ verify(span).setError(true);
+ verify(span).addThrowable(err);
+ }
+
+ @Test
+ void errorWithoutThrowableSetsErrorFlagOnBothBackends() {
+ allBackends().withError(null).finish();
+ verify(obsSpan).setError(true);
+ verify(obsSpan, never()).addThrowable(any());
+ verify(span).setError(true);
+ verify(span, never()).addThrowable(any());
+ }
+
+ @Test
+ void noErrorByDefaultOnBothBackends() {
+ allBackends().finish();
+ verify(obsSpan, never()).setError(true);
+ verify(span, never()).setError(true);
+ }
+
+ // --- I/O forwarding ---
+
+ @Test
+ void tokenMetricsForwardedToObsSpan() {
+ allBackends().withTokenMetrics(3, 7).finish();
+ verify(obsSpan).setMetric("input_tokens", 3);
+ verify(obsSpan).setMetric("output_tokens", 7);
+ }
+
+ @Test
+ void structuredMessagesForwardedToObsSpan() {
+ List inputs = List.of(LLMObs.LLMMessage.from("user", "hello"));
+ List outputs = List.of(LLMObs.LLMMessage.from("assistant", "hi"));
+ allBackends().withInput(inputs).withOutput(outputs).finish();
+ verify(obsSpan).annotateIO(inputs, outputs);
+ }
+
+ @Test
+ void plainTextIoForwardedToObsSpan() {
+ allBackends().withInput("hello").withOutput("hi").finish();
+ verify(obsSpan).annotateIO("hello", "hi");
+ }
+
+ // --- null-safety ---
+
+ @Test
+ void nullJfrEventHandledGracefully() {
+ assertDoesNotThrow(() -> new LlmCallHandle(null, obsSpan, scope).finish());
+ }
+
+ @Test
+ void nullObsSpanHandledGracefully() {
+ assertDoesNotThrow(() -> new LlmCallHandle(makeJfrEvent(), null, scope).finish());
+ }
+
+ @Test
+ void nullScopeHandledGracefully() {
+ assertDoesNotThrow(() -> new LlmCallHandle(makeJfrEvent(), obsSpan, null).finish());
+ }
+
+ @Test
+ void allNullHandleFinishesGracefully() {
+ assertDoesNotThrow(() -> new LlmCallHandle(null, null, null).finish());
+ }
+
+ // --- async lifecycle ---
+
+ @Test
+ void asyncClosesscopeOnEntryAndNotAgainOnFinish() {
+ LlmCallHandle handle = allBackends();
+ handle.async();
+ verify(scope, times(1)).close(); // closed on entering thread
+ handle.finish();
+ verify(scope, times(1)).close(); // not closed a second time in doFinish
+ verify(span, times(1)).finish(); // span still finished
+ }
+}
diff --git a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java
index 629faa23f5a..99100a60af7 100644
--- a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java
+++ b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java
@@ -12,6 +12,23 @@ protected LLMObs() {}
protected static LLMObsSpanFactory SPAN_FACTORY = NoOpLLMObsSpanFactory.INSTANCE;
protected static LLMObsEvalProcessor EVAL_PROCESSOR = NoOpLLMObsEvalProcessor.INSTANCE;
+ /**
+ * Returns {@code true} when LLM Observability is active, i.e. the agent has registered a real
+ * span factory. Returns {@code false} when LLMObs is not configured, in which case all {@code
+ * startXxxSpan} calls are no-ops and no data is collected or exported.
+ *
+ * Use this check to avoid constructing inputs that would be discarded:
+ *
+ *
+ * if (LLMObs.isEnabled()) {
+ * LLMObsSpan span = LLMObs.startLLMSpan(...);
+ * }
+ *
+ */
+ public static boolean isEnabled() {
+ return !(SPAN_FACTORY instanceof NoOpLLMObsSpanFactory);
+ }
+
public static LLMObsSpan startLLMSpan(
String spanName,
String modelName,
diff --git a/docs/add_new_llm_instrumentation.md b/docs/add_new_llm_instrumentation.md
new file mode 100644
index 00000000000..c380db1b9ae
--- /dev/null
+++ b/docs/add_new_llm_instrumentation.md
@@ -0,0 +1,375 @@
+# Add a New LLM Framework Instrumentation
+
+This guide explains how to add instrumentation for a new LLM framework using the shared
+LLM SPI (`LlmObsHandle` / `LlmCallHandle`). The SPI emits three correlated signals for
+every instrumented operation:
+
+- **APM span** — visible in Trace Explorer; carries `component=` and the
+ appropriate `span.kind`
+- **LLMObs span** — visible in LLM Observability; carries input/output messages and token
+ metrics
+- **JFR duration event** — visible in Continuous Profiler; carries the LLMObs (or APM)
+ span's `traceId`/`spanId` for cross-product correlation
+
+The [LangChain4j 1.0 instrumentation](../dd-java-agent/instrumentation/langchain4j/langchain4j-1.0)
+is the reference implementation for this pattern.
+
+> [!IMPORTANT]
+> **This SPI is the proposed unified approach for all new LLM instrumentations.**
+> New integrations — whether they target a framework like LangChain4j or a direct API
+> client like an OpenAI SDK — should use `LlmCallHandle` and `LlmObsHandle` as the
+> single lifecycle contract.
+>
+> The existing OpenAI Java SDK instrumentation pre-dates this SPI and is kept at its
+> current state (standalone `AgentSpan`-based decorator pattern) out of caution: its
+> async/streaming response-wrapper pattern requires additional design work before the
+> `LlmCallHandle` lifecycle can be applied safely. It will be migrated in a follow-up.
+> Do not model new work on it.
+
+## Prerequisites
+
+- Familiarity with [How Instrumentations Work](./how_instrumentations_work.md)
+- Java 11 or later (JFR event classes are not available on Java 8)
+- The new module must set `testJvmConstraints { minJavaVersion = JavaVersion.VERSION_17 }`
+ in its `build.gradle`
+
+## Module layout
+
+```
+dd-java-agent/instrumentation//-/
+ build.gradle
+ src/
+ main/java/datadog/trace/instrumentation//
+ ProfilingModule.java ← InstrumenterModule
+ LlmObsIntegration.java ← factory / span-start helpers
+ ChatModelInstrumentation.java ← one file per intercepted type
+ ...
+ testFixtures/java/datadog/trace/instrumentation/llm/tck/
+ (LlmObsHandleTck.java is inherited from langchain4j — see TCK section)
+ test/java/datadog/trace/instrumentation//
+ LlmCallHandleTckTest.java ← extends LlmObsHandleTck
+```
+
+## Step 1 — Register the module
+
+Add the new module to `settings.gradle.kts` in alpha order with the other
+instrumentations (see [Configuring Gradle](./add_new_instrumentation.md#configuring-gradle)):
+
+```kotlin
+":dd-java-agent:instrumentation::-",
+```
+
+## Step 2 — Create `build.gradle`
+
+```gradle
+plugins {
+ id 'java-test-fixtures'
+}
+
+apply from: "$rootDir/gradle/java.gradle"
+
+def minVer = ''
+
+testJvmConstraints {
+ minJavaVersion = JavaVersion.VERSION_17
+}
+
+// JFR event classes require Java 11+
+tasks.named("compileJava", JavaCompile) {
+ configureCompiler(it, 17, JavaVersion.VERSION_1_8, "LLM instrumentation requires Java 11+ for JFR")
+}
+tasks.named("compileTestJava", JavaCompile) {
+ configureCompiler(it, 17, JavaVersion.VERSION_1_8, "LLM instrumentation requires Java 11+ for JFR")
+}
+tasks.named("compileTestFixturesJava", JavaCompile) {
+ configureCompiler(it, 17, JavaVersion.VERSION_1_8, "LLM TCK requires Java 11+ for JFR")
+}
+
+muzzle {
+ pass {
+ group = ""
+ module = ""
+ versions = "[$minVer,)"
+ }
+}
+
+addTestSuiteForDir('latestDepTest', 'test')
+
+dependencies {
+ // TCK — mandatory for every LLM instrumentation
+ testFixturesApi project(':dd-java-agent:agent-bootstrap')
+ testFixturesCompileOnly project(':internal-api')
+ testFixturesCompileOnly libs.bundles.junit5
+ testFixturesCompileOnly libs.bundles.mockito
+
+ compileOnly group: '', name: '', version: minVer
+ testImplementation group: '', name: '', version: minVer
+}
+```
+
+## Step 3 — Create the `InstrumenterModule`
+
+The module class activates when **any** of tracing, profiling, or LLMObs is enabled and
+registers the integration factory as a helper class:
+
+```java
+@AutoService(InstrumenterModule.class)
+public class MyFrameworkModule extends InstrumenterModule {
+
+ public MyFrameworkModule() {
+ super("my-framework");
+ }
+
+ @Override
+ public boolean isApplicable(Set enabledSystems) {
+ return enabledSystems.contains(TRACING)
+ || enabledSystems.contains(PROFILING)
+ || enabledSystems.contains(LLMOBS);
+ }
+
+ @Override
+ public String[] helperClassNames() {
+ return new String[] {packageName + ".MyFrameworkLlmObsIntegration"};
+ }
+
+ @Override
+ public List typeInstrumentations() {
+ return Arrays.asList(
+ new ChatModelInstrumentation(),
+ /* add further type instrumentations here */);
+ }
+}
+```
+
+> [!IMPORTANT]
+> `helperClassNames()` must list **only** classes that are loaded by the instrumentation
+> classloader (i.e. your own integration classes). Bootstrap classes
+> (`AgentTracer`, `Tags`, `UTF8BytesString`, `LlmCallHandle`, etc.) are on the bootstrap
+> classpath and must **not** be listed here.
+
+## Step 4 — Create the integration factory
+
+The factory class creates and returns `LlmObsHandle` instances. It must:
+
+1. Start a JFR event and call `begin()` on it
+2. Check whether each backend is active before starting spans
+3. Activate the APM span **inside a try/catch** so an exception from the LLMObs
+ factory cannot leave an orphaned scope on the thread-local stack
+4. Fall back to APM span IDs for JFR correlation when LLMObs is disabled
+
+Use `LangChain4jLlmObsIntegration` as the reference implementation:
+
+```java
+public final class MyFrameworkLlmObsIntegration {
+
+ public static final MyFrameworkLlmObsIntegration INSTANCE =
+ new MyFrameworkLlmObsIntegration();
+
+ private static final String INSTRUMENTATION_NAME = "my-framework";
+ private static final CharSequence OPERATION_LLM =
+ UTF8BytesString.create("my-framework.chat_model.request");
+
+ private MyFrameworkLlmObsIntegration() {}
+
+ public LlmObsHandle startLlm(String modelId) {
+ MyChatModelEvent jfrEvent = new MyChatModelEvent(modelId);
+ boolean jfrActive = jfrEvent.isEnabled();
+ boolean obsActive = LLMObs.isEnabled();
+ AgentScope agentScope = startApmSpan(OPERATION_LLM, modelId, Tags.SPAN_KIND_CLIENT);
+ if (!jfrActive && !obsActive && agentScope == null) return LlmObsHandle.NOOP;
+ try {
+ LLMObsSpan obsSpan =
+ obsActive ? LLMObs.startLLMSpan(modelId, modelId, null, null, null) : null;
+ setJfrSpanContext(jfrEvent, jfrActive, obsSpan, agentScope);
+ return new LlmCallHandle(jfrActive ? jfrEvent : null, obsSpan, agentScope);
+ } catch (Throwable t) {
+ abortApmSpan(agentScope);
+ return LlmObsHandle.NOOP;
+ }
+ }
+
+ private static AgentScope startApmSpan(
+ CharSequence operationName, String resourceName, String spanKind) {
+ if (!AgentTracer.isRegistered() || !Config.get().isTraceEnabled()) return null;
+ AgentSpan span = AgentTracer.startSpan(INSTRUMENTATION_NAME, operationName);
+ span.setTag(Tags.COMPONENT, INSTRUMENTATION_NAME);
+ span.setTag(Tags.SPAN_KIND, spanKind);
+ span.setResourceName(resourceName != null ? resourceName : "unknown");
+ return AgentTracer.activateSpan(span);
+ }
+
+ private static void abortApmSpan(AgentScope agentScope) {
+ if (agentScope == null) return;
+ agentScope.span().finish();
+ agentScope.close();
+ }
+}
+```
+
+### Span kind conventions
+
+| Operation type | `span.kind` |
+|---|---|
+| LLM chat / completion call (external network) | `Tags.SPAN_KIND_CLIENT` |
+| AI service / orchestration (internal) | `Tags.SPAN_KIND_INTERNAL` |
+| Tool execution (local code) | `Tags.SPAN_KIND_INTERNAL` |
+
+## Step 5 — Create JFR event classes
+
+Create one event class per logical operation tier. Place them in
+`agent-bootstrap/src/main/java11/datadog/trace/bootstrap/instrumentation/jfr/llm/` and
+apply the `@LLMOperation` meta-annotation:
+
+```java
+@Name("datadog.MyChatModel")
+@Label("My Chat Model")
+@Description("LLM chat model invocation via My Framework")
+@Category({"Datadog", "LLM"})
+@StackTrace(false)
+@LLMOperation
+public class MyChatModelEvent extends Event {
+
+ @Label("Model Id")
+ private final String modelId;
+
+ @Label("Trace ID")
+ private String traceId;
+
+ @Label("Span ID")
+ private String spanId;
+
+ public MyChatModelEvent(String modelId) {
+ this.modelId = modelId;
+ begin();
+ }
+
+ public void setSpanContext(String traceId, String spanId) {
+ this.traceId = traceId;
+ this.spanId = spanId;
+ }
+}
+```
+
+Register the events in the JFR configuration file
+`dd-java-agent/agent-profiling/profiling-controller-jfr/src/main/resources/jfr/dd.jfp`:
+
+```
+datadog.MyChatModel#enabled=true
+```
+
+> [!NOTE]
+> Do **not** add a `#threshold` unless you intentionally want to suppress short-lived
+> events. LLM operations should always be recorded regardless of duration.
+
+## Step 6 — Write advice classes
+
+Each advice class intercepts one target method. Use `@Advice.Local("handle")` to pass
+the `LlmObsHandle` between enter and exit, and match `suppress = Throwable.class` on
+both sides so the agent never crashes the instrumented application:
+
+```java
+public static final class ChatAdvice {
+
+ @Advice.OnMethodEnter(suppress = Throwable.class)
+ public static void enter(
+ @Advice.Argument(0) MyChatRequest request,
+ @Advice.Local("handle") LlmObsHandle handle) {
+ if (request == null) return;
+ handle = MyFrameworkLlmObsIntegration.INSTANCE.startLlm(request.modelId());
+ // populate inputs from the request
+ }
+
+ @Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
+ public static void exit(
+ @Advice.Local("handle") LlmObsHandle handle,
+ @Advice.Return MyChatResponse response,
+ @Advice.Thrown Throwable err) {
+ if (handle == null) return;
+ if (response != null) {
+ handle.withOutput(/* extract output from response */);
+ // optionally: handle.withTokenMetrics(in, out);
+ }
+ if (err != null) handle.withError(err);
+ handle.finish();
+ }
+}
+```
+
+> [!WARNING]
+> Always guard `@Advice.Argument(0)` with a `null` check before calling any method on it.
+> ByteBuddy injects `null` when the argument is unavailable, and a `NullPointerException`
+> is silently swallowed by `suppress = Throwable.class` — leaving no span and no error
+> signal.
+
+### Handle lifecycle summary
+
+```
+enter: handle = integration.startLlm(modelId)
+ handle.withInput(messages)
+exit: handle.withOutput(messages)
+ handle.withTokenMetrics(inputTokens, outputTokens) // if available
+ handle.withError(thrown) // if thrown != null
+ handle.finish()
+```
+
+`finish()` is idempotent and thread-safe; calling it more than once is harmless.
+
+## Step 7 — Pass the TCK
+
+Every LLM instrumentation **must** pass `LlmObsHandleTck`. The TCK is published as a
+test fixture of the `langchain4j-1.0` module and verifies the `LlmCallHandle` lifecycle
+contract: finish idempotency, scope-before-span ordering, exception safety, error
+propagation, I/O forwarding, and async scope lifecycle.
+
+### Add the TCK dependency
+
+```gradle
+// build.gradle
+dependencies {
+ testImplementation(testFixtures(project(':dd-java-agent:instrumentation:langchain4j:langchain4j-1.0')))
+ // ...
+}
+```
+
+### Extend the TCK
+
+Create a test class that extends `LlmObsHandleTck` and supplies the JFR event class
+specific to the new integration:
+
+```java
+class MyChatModelHandleTckTest extends LlmObsHandleTck {
+
+ @Override
+ protected Event makeJfrEvent() {
+ // Return a JFR event with begin() already called, as advice does at method entry.
+ return new MyChatModelEvent("test-model");
+ }
+}
+```
+
+The TCK runs 15 test cases automatically. No further test code is required to satisfy the
+contract.
+
+> [!NOTE]
+> The TCK tests the **handle lifecycle** only (mocked APM scope and LLMObs span). It does
+> not test that advice fires correctly under the agent, nor that the integration factory
+> wires spans end-to-end with real infrastructure. Those are covered by separate
+> instrumented tests (see [How to Test](./how_to_test.md)).
+
+## Checklist
+
+Before opening a PR for a new LLM instrumentation:
+
+- [ ] `LlmCallHandleTckTest` (or equivalent) passes all 15 TCK cases
+- [ ] `@Advice.Argument(0)` guarded with `null` check in every enter advice
+- [ ] APM span kind is `SPAN_KIND_CLIENT` for external LLM calls, `SPAN_KIND_INTERNAL` for
+ orchestration and local tool execution
+- [ ] `startApmSpan` is gated on `Config.get().isTraceEnabled()` (not just
+ `AgentTracer.isRegistered()`) to avoid emitting APM spans in PROFILING-only or
+ LLMOBS-only deployments
+- [ ] JFR events registered in `dd.jfp` without a `#threshold`
+- [ ] `helperClassNames()` lists only the integration factory class, not bootstrap classes
+- [ ] New JFR event class annotated with `@LLMOperation` and placed in the
+ `bootstrap/instrumentation/jfr/llm` package
+- [ ] Module registered in `settings.gradle.kts`
+- [ ] PR carries `inst:`, `type:feature`, and `tag: ai generated` labels
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 487f6275cf1..2071b0af798 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -500,6 +500,7 @@ include(
":dd-java-agent:instrumentation:ognl-appsec-3.3.2",
":dd-java-agent:instrumentation:okhttp:okhttp-2.2",
":dd-java-agent:instrumentation:okhttp:okhttp-3.0",
+ ":dd-java-agent:instrumentation:langchain4j:langchain4j-1.0",
":dd-java-agent:instrumentation:openai-java:openai-java-3.0",
":dd-java-agent:instrumentation:opensearch:opensearch-rest-1.0",
":dd-java-agent:instrumentation:opensearch:opensearch-transport-1.0",