diff --git a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/EvalProcessingWorker.java b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsIntakeWorker.java
similarity index 53%
rename from dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/EvalProcessingWorker.java
rename to dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsIntakeWorker.java
index 15e207428f2..68916a33fe2 100644
--- a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/EvalProcessingWorker.java
+++ b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsIntakeWorker.java
@@ -1,11 +1,8 @@
package datadog.trace.llmobs;
-import static datadog.trace.util.AgentThreadFactory.AgentThread.LLMOBS_EVALS_PROCESSOR;
import static datadog.trace.util.AgentThreadFactory.THREAD_JOIN_TIMOUT_MS;
import static datadog.trace.util.AgentThreadFactory.newAgentThread;
-import com.squareup.moshi.JsonAdapter;
-import com.squareup.moshi.Moshi;
import datadog.common.queue.MessagePassingBlockingQueue;
import datadog.common.queue.Queues;
import datadog.communication.ddagent.DDAgentFeaturesDiscovery;
@@ -13,7 +10,7 @@
import datadog.communication.http.HttpRetryPolicy;
import datadog.communication.http.OkHttpUtils;
import datadog.trace.api.Config;
-import datadog.trace.llmobs.domain.LLMObsEval;
+import datadog.trace.util.AgentThreadFactory.AgentThread;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -25,64 +22,74 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-public class EvalProcessingWorker implements AutoCloseable {
+/**
+ * Batches LLM Observability payloads on a dedicated thread and posts them to an intake endpoint.
+ *
+ *
Evaluations and feedback share this machinery but not an endpoint: evaluations are submitted
+ * to {@code v1/eval-metric} and feedback to {@code v2/eval-metric}, so each gets its own instance
+ * with its own queue and its own batch.
+ *
+ * @param the payload type this worker submits
+ */
+public class LLMObsIntakeWorker implements AutoCloseable {
- private static final String EVAL_METRIC_API_DOMAIN = "api";
- private static final String EVAL_METRIC_API_PATH = "api/intake/llm-obs/v1/eval-metric";
+ private static final String INTAKE_API_DOMAIN = "api";
private static final String EVP_SUBDOMAIN_HEADER_NAME = "X-Datadog-EVP-Subdomain";
private static final String DD_API_KEY_HEADER_NAME = "DD-API-KEY";
- private static final Logger log = LoggerFactory.getLogger(EvalProcessingWorker.class);
+ private static final Logger log = LoggerFactory.getLogger(LLMObsIntakeWorker.class);
- private final MessagePassingBlockingQueue queue;
+ /** Serializes a whole batch into the request body sent to the intake. */
+ public interface BatchSerializer {
+ String toJson(List batch);
+ }
+
+ private final MessagePassingBlockingQueue queue;
private final Thread serializerThread;
- public EvalProcessingWorker(
+ public LLMObsIntakeWorker(
+ final String payloadDescription,
+ final String apiPath,
+ final AgentThread agentThread,
final int capacity,
final long flushInterval,
final TimeUnit timeUnit,
final SharedCommunicationObjects sco,
- Config config) {
+ final Config config,
+ final BatchSerializer serializer) {
this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity);
boolean isAgentless = config.isLlmObsAgentlessEnabled();
if (isAgentless && (config.getApiKey() == null || config.getApiKey().isEmpty())) {
- log.error("Agentless eval metric submission requires an API key");
+ log.error("Agentless {} submission requires an API key", payloadDescription);
}
Headers headers;
HttpUrl submissionUrl;
if (isAgentless) {
submissionUrl =
- HttpUrl.get(
- "https://"
- + EVAL_METRIC_API_DOMAIN
- + "."
- + config.getSite()
- + "/"
- + EVAL_METRIC_API_PATH);
+ HttpUrl.get("https://" + INTAKE_API_DOMAIN + "." + config.getSite() + "/" + apiPath);
headers = Headers.of(DD_API_KEY_HEADER_NAME, config.getApiKey());
} else {
submissionUrl =
HttpUrl.get(
- sco.agentUrl.toString()
- + DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT
- + EVAL_METRIC_API_PATH);
- headers = Headers.of(EVP_SUBDOMAIN_HEADER_NAME, EVAL_METRIC_API_DOMAIN);
+ sco.agentUrl.toString() + DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT + apiPath);
+ headers = Headers.of(EVP_SUBDOMAIN_HEADER_NAME, INTAKE_API_DOMAIN);
}
- EvalSerializingHandler serializingHandler =
- new EvalSerializingHandler(queue, flushInterval, timeUnit, submissionUrl, headers);
- this.serializerThread = newAgentThread(LLMOBS_EVALS_PROCESSOR, serializingHandler);
+ SerializingHandler serializingHandler =
+ new SerializingHandler<>(
+ payloadDescription, queue, flushInterval, timeUnit, submissionUrl, headers, serializer);
+ this.serializerThread = newAgentThread(agentThread, serializingHandler);
}
public void start() {
this.serializerThread.start();
}
- public boolean addToQueue(final LLMObsEval eval) {
- return queue.offer(eval);
+ public boolean addToQueue(final T payload) {
+ return queue.offer(payload);
}
@Override
@@ -94,33 +101,34 @@ public void close() {
}
}
- public static class EvalSerializingHandler implements Runnable {
+ public static class SerializingHandler implements Runnable {
- private static final Logger log = LoggerFactory.getLogger(EvalSerializingHandler.class);
+ private static final Logger log = LoggerFactory.getLogger(SerializingHandler.class);
private static final int FLUSH_THRESHOLD = 50;
- private final MessagePassingBlockingQueue queue;
+ private final String payloadDescription;
+ private final MessagePassingBlockingQueue queue;
private final long ticksRequiredToFlush;
private long lastTicks;
- private final Moshi moshi;
- private final JsonAdapter evalJsonAdapter;
+ private final BatchSerializer serializer;
private final OkHttpClient httpClient;
private final HttpUrl submissionUrl;
private final Headers headers;
- private final List buffer = new ArrayList<>();
+ private final List buffer = new ArrayList<>();
- public EvalSerializingHandler(
- final MessagePassingBlockingQueue queue,
+ public SerializingHandler(
+ final String payloadDescription,
+ final MessagePassingBlockingQueue queue,
final long flushInterval,
final TimeUnit timeUnit,
final HttpUrl submissionUrl,
- final Headers headers) {
+ final Headers headers,
+ final BatchSerializer serializer) {
+ this.payloadDescription = payloadDescription;
this.queue = queue;
- this.moshi = new Moshi.Builder().add(LLMObsEval.class, new LLMObsEval.Adapter()).build();
-
- this.evalJsonAdapter = moshi.adapter(LLMObsEval.Request.class);
+ this.serializer = serializer;
this.httpClient = new OkHttpClient();
this.submissionUrl = submissionUrl;
this.headers = headers;
@@ -128,7 +136,7 @@ public EvalSerializingHandler(
this.lastTicks = System.nanoTime();
this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval);
- log.debug("starting eval metric serializer, url={}", submissionUrl);
+ log.debug("starting {} serializer, url={}", payloadDescription, submissionUrl);
}
@Override
@@ -139,16 +147,17 @@ public void run() {
Thread.currentThread().interrupt();
}
log.debug(
- "eval processor worker exited. submitting evals stopped. unsubmitted evals left: {}",
+ "{} processor worker exited. submitting stopped. unsubmitted payloads left: {}",
+ payloadDescription,
!queuesAreEmpty());
}
private void runDutyCycle() throws InterruptedException {
Thread thread = Thread.currentThread();
while (!thread.isInterrupted()) {
- LLMObsEval eval = queue.poll(100, TimeUnit.MILLISECONDS);
- if (eval != null) {
- buffer.add(eval);
+ T payload = queue.poll(100, TimeUnit.MILLISECONDS);
+ if (payload != null) {
+ buffer.add(payload);
consumeBatch();
}
flushIfNecessary();
@@ -164,10 +173,22 @@ protected void flushIfNecessary() {
return;
}
if (shouldFlush()) {
- LLMObsEval.Request llmobsEvalReq = new LLMObsEval.Request(this.buffer);
HttpRetryPolicy.Factory retryPolicyFactory = new HttpRetryPolicy.Factory(5, 100, 2.0, true);
- String reqBod = evalJsonAdapter.toJson(llmobsEvalReq);
+ String reqBod;
+ try {
+ reqBod = serializer.toJson(this.buffer);
+ } catch (Exception e) {
+ // A batch that cannot be serialized will never serialize, so it is dropped rather than
+ // retried. Letting this escape would kill the worker and strand every later payload.
+ log.error(
+ "Could not serialize {} payloads, dropping {} of them",
+ payloadDescription,
+ this.buffer.size(),
+ e);
+ this.buffer.clear();
+ return;
+ }
RequestBody requestBody =
RequestBody.create(okhttp3.MediaType.parse("application/json"), reqBod);
@@ -178,16 +199,20 @@ protected void flushIfNecessary() {
OkHttpUtils.sendWithRetries(httpClient, retryPolicyFactory, request)) {
if (response.isSuccessful()) {
- log.debug("successfully flushed evaluation request with {} evals", this.buffer.size());
+ log.debug(
+ "successfully flushed {} request with {} payloads",
+ payloadDescription,
+ this.buffer.size());
this.buffer.clear();
} else {
log.error(
- "Could not submit eval metrics (HTTP code {}) {}",
+ "Could not submit {} (HTTP code {}) {}",
+ payloadDescription,
response.code(),
response.body() != null ? response.body().string() : "");
}
} catch (Exception e) {
- log.error("Could not submit eval metrics", e);
+ log.error("Could not submit " + payloadDescription, e);
}
}
}
diff --git a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsSystem.java b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsSystem.java
index a57dd858b45..9c01eb4a2d1 100644
--- a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsSystem.java
+++ b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsSystem.java
@@ -6,10 +6,13 @@
import datadog.trace.api.llmobs.LLMObs;
import datadog.trace.api.llmobs.LLMObsSpan;
import datadog.trace.api.llmobs.LLMObsTags;
+import datadog.trace.api.telemetry.LLMObsMetricCollector;
import datadog.trace.bootstrap.instrumentation.api.Tags;
import datadog.trace.llmobs.domain.DDLLMObsSpan;
import datadog.trace.llmobs.domain.LLMObsEval;
+import datadog.trace.llmobs.domain.LLMObsFeedbackEvent;
import datadog.trace.llmobs.domain.LLMObsInternal;
+import datadog.trace.util.AgentThreadFactory.AgentThread;
import java.lang.instrument.Instrumentation;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -23,6 +26,15 @@ public class LLMObsSystem {
private static final String CUSTOM_MODEL_VAL = "custom";
+ private static final String EVAL_METRIC_API_PATH = "api/intake/llm-obs/v1/eval-metric";
+ // Feedback is a v2 concept: submitter, the feedback-only targets and the non-score value types
+ // only exist there. Evaluations deliberately stay on v1 with their existing flat payload; both
+ // now carry event_kind, so the two are told apart the same way as in dd-trace-py and dd-trace-js.
+ private static final String FEEDBACK_API_PATH = "api/intake/llm-obs/v2/eval-metric";
+
+ private static final int QUEUE_CAPACITY = 1024;
+ private static final long FLUSH_INTERVAL_MS = 100;
+
public static void start(Instrumentation inst, SharedCommunicationObjects sco) {
Config config = Config.get();
if (!config.isLlmObsEnabled()) {
@@ -37,18 +49,99 @@ public static void start(Instrumentation inst, SharedCommunicationObjects sco) {
LLMObsInternal.setLLMObsSpanFactory(new LLMObsManualSpanFactory(mlApp, wellKnownTags));
LLMObsInternal.setLLMObsEvalProcessor(new LLMObsCustomEvalProcessor(mlApp, sco, config));
+
+ LLMObsInternal.setLLMObsFeedbackProcessor(
+ new LLMObsCustomFeedbackProcessor(mlApp, sco, config));
+ }
+
+ private static class LLMObsCustomFeedbackProcessor implements LLMObs.LLMObsFeedbackProcessor {
+ private final String defaultMLApp;
+ private final LLMObsIntakeWorker feedbackProcessingWorker;
+
+ public LLMObsCustomFeedbackProcessor(
+ String defaultMLApp, SharedCommunicationObjects sco, Config config) {
+
+ this.defaultMLApp = defaultMLApp;
+ this.feedbackProcessingWorker =
+ new LLMObsIntakeWorker<>(
+ "feedback",
+ FEEDBACK_API_PATH,
+ AgentThread.LLMOBS_FEEDBACK_PROCESSOR,
+ QUEUE_CAPACITY,
+ FLUSH_INTERVAL_MS,
+ TimeUnit.MILLISECONDS,
+ sco,
+ config,
+ LLMObsFeedbackEvent.batchSerializer());
+ this.feedbackProcessingWorker.start();
+ }
+
+ @Override
+ public void submitFeedback(LLMObs.Feedback feedback) {
+ if (feedback == null) {
+ LLMObsMetricCollector.get().recordFeedbackSubmitted(null, null, "invalid_feedback");
+ LOGGER.error("null feedback provided, feedback not recorded");
+ return;
+ }
+
+ // The builder never throws so that instrumented code stays safe when the agent is absent;
+ // validation happens here instead, only once LLM Observability is actually enabled.
+ LLMObs.Feedback.ValidationError error = feedback.validate();
+ if (error != null) {
+ recordFeedbackTelemetry(feedback, error.getCode());
+ throw new IllegalArgumentException(error.getMessage());
+ }
+
+ String mlApp = feedback.getMlApp();
+ if (mlApp == null || mlApp.isEmpty()) {
+ mlApp = defaultMLApp;
+ }
+
+ if (!this.feedbackProcessingWorker.addToQueue(new LLMObsFeedbackEvent(feedback, mlApp))) {
+ recordFeedbackTelemetry(feedback, "queue_full");
+ LOGGER.warn(
+ "queue full, failed to add feedback, ml_app={}, {}={}, label={}",
+ mlApp,
+ feedback.getTargetType().getWireKey(),
+ feedback.getTargetValue(),
+ feedback.getLabel());
+ return;
+ }
+
+ recordFeedbackTelemetry(feedback, null);
+ }
+
+ private static void recordFeedbackTelemetry(
+ LLMObs.Feedback feedback, @Nullable String errorCode) {
+ LLMObs.Feedback.MetricType metricType = feedback.getMetricType();
+ LLMObs.Feedback.TargetType targetType = feedback.getTargetType();
+ LLMObsMetricCollector.get()
+ .recordFeedbackSubmitted(
+ metricType == null ? null : metricType.toString(),
+ targetType == null ? null : targetType.getWireKey(),
+ errorCode);
+ }
}
private static class LLMObsCustomEvalProcessor implements LLMObs.LLMObsEvalProcessor {
private final String defaultMLApp;
- private final EvalProcessingWorker evalProcessingWorker;
+ private final LLMObsIntakeWorker evalProcessingWorker;
public LLMObsCustomEvalProcessor(
String defaultMLApp, SharedCommunicationObjects sco, Config config) {
this.defaultMLApp = defaultMLApp;
this.evalProcessingWorker =
- new EvalProcessingWorker(1024, 100, TimeUnit.MILLISECONDS, sco, config);
+ new LLMObsIntakeWorker<>(
+ "eval metrics",
+ EVAL_METRIC_API_PATH,
+ AgentThread.LLMOBS_EVALS_PROCESSOR,
+ QUEUE_CAPACITY,
+ FLUSH_INTERVAL_MS,
+ TimeUnit.MILLISECONDS,
+ sco,
+ config,
+ LLMObsEval.batchSerializer());
this.evalProcessingWorker.start();
}
diff --git a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/LLMObsEval.java b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/LLMObsEval.java
index 58d3c5dbb64..c38d84438a4 100644
--- a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/LLMObsEval.java
+++ b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/LLMObsEval.java
@@ -5,6 +5,7 @@
import com.squareup.moshi.JsonReader;
import com.squareup.moshi.JsonWriter;
import com.squareup.moshi.Moshi;
+import datadog.trace.llmobs.LLMObsIntakeWorker;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -15,6 +16,14 @@ public abstract class LLMObsEval {
private static final String METRIC_TYPE_SCORE = "score";
private static final String METRIC_TYPE_CATEGORICAL = "categorical";
+ private static final String EVENT_KIND_EVALUATION = "evaluation";
+
+ /**
+ * Discriminates evaluations from feedback, mirroring dd-trace-py and dd-trace-js. Purely
+ * additive: the rest of the v1 payload is unchanged.
+ */
+ public final String event_kind = EVENT_KIND_EVALUATION;
+
public final String trace_id;
public final String span_id;
public final long timestamp_ms;
@@ -48,6 +57,17 @@ public LLMObsEval(
}
}
+ /**
+ * Returns a serializer turning a batch of evaluations into an intake request body.
+ *
+ * @return the batch serializer
+ */
+ public static LLMObsIntakeWorker.BatchSerializer batchSerializer() {
+ Moshi moshi = new Moshi.Builder().add(LLMObsEval.class, new Adapter()).build();
+ JsonAdapter requestAdapter = moshi.adapter(Request.class);
+ return batch -> requestAdapter.toJson(new Request(batch));
+ }
+
public static final class Adapter extends JsonAdapter {
private final Moshi moshi = new Moshi.Builder().build();
private final JsonAdapter scoreJsonAdapter = moshi.adapter(Score.class);
diff --git a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/LLMObsFeedbackEvent.java b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/LLMObsFeedbackEvent.java
new file mode 100644
index 00000000000..b49822b274b
--- /dev/null
+++ b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/LLMObsFeedbackEvent.java
@@ -0,0 +1,143 @@
+package datadog.trace.llmobs.domain;
+
+import com.squareup.moshi.JsonAdapter;
+import com.squareup.moshi.JsonDataException;
+import com.squareup.moshi.JsonReader;
+import com.squareup.moshi.JsonWriter;
+import com.squareup.moshi.Moshi;
+import datadog.trace.api.DDTraceApiInfo;
+import datadog.trace.api.llmobs.LLMObs;
+import datadog.trace.llmobs.LLMObsIntakeWorker;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/**
+ * An end-user feedback event, as sent to the eval metric intake.
+ *
+ * Feedback shares the {@code evaluation_metric} envelope with {@link LLMObsEval} and is told
+ * apart on the wire by {@code event_kind}, mirroring dd-trace-py and dd-trace-js.
+ */
+public final class LLMObsFeedbackEvent {
+
+ private static final String EVENT_KIND_FEEDBACK = "feedback";
+
+ private final LLMObs.Feedback feedback;
+ private final String mlApp;
+ private final List tags;
+
+ public LLMObsFeedbackEvent(LLMObs.Feedback feedback, String mlApp) {
+ this.feedback = feedback;
+ this.mlApp = mlApp;
+ this.tags = buildTags(feedback.getTags(), mlApp);
+ }
+
+ public LLMObs.Feedback getFeedback() {
+ return feedback;
+ }
+
+ public String getMlApp() {
+ return mlApp;
+ }
+
+ public List getTags() {
+ return tags;
+ }
+
+ private static List buildTags(@Nullable Map userTags, String mlApp) {
+ List tagList = new ArrayList<>((userTags == null ? 0 : userTags.size()) + 2);
+ tagList.add("ddtrace.version:" + DDTraceApiInfo.VERSION);
+ tagList.add("ml_app:" + mlApp);
+ if (userTags != null) {
+ for (Map.Entry entry : userTags.entrySet()) {
+ tagList.add(entry.getKey() + ":" + entry.getValue());
+ }
+ }
+ return tagList;
+ }
+
+ /**
+ * Returns a serializer turning a batch of feedback events into an intake request body.
+ *
+ * @return the batch serializer
+ */
+ public static LLMObsIntakeWorker.BatchSerializer batchSerializer() {
+ Moshi moshi = new Moshi.Builder().add(LLMObsFeedbackEvent.class, new Adapter()).build();
+ JsonAdapter requestAdapter = moshi.adapter(Request.class);
+ return batch -> requestAdapter.toJson(new Request(batch));
+ }
+
+ public static final class Adapter extends JsonAdapter {
+ private final JsonAdapter