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: + * + *

+ * + * 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",