This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 4e8f7dcc4 [integration][observability] Add OpenTelemetry GenAI
exporter for Agent Traces (Phase 1) (#1160)
4e8f7dcc4 is described below
commit 4e8f7dcc4a2bd153f33f0317a212ead368c0b263
Author: Edson <[email protected]>
AuthorDate: Mon Oct 26 05:18:06 2026 -0400
[integration][observability] Add OpenTelemetry GenAI exporter for Agent
Traces (Phase 1) (#1160)
Generated-by: Claude Code 2.1.259 (Claude Fable 5, Claude Opus 5.5)
---
docs/content/docs/operations/monitoring.md | 57 ++
integrations/observability/otel/pom.xml | 79 +++
.../observability/otel/AgentTraceSpanData.java | 163 +++++
.../observability/otel/AgentTraceSpans.java | 548 ++++++++++++++
.../observability/otel/ConverterDiagnostic.java | 89 +++
.../integrations/observability/otel/OTelIds.java | 83 +++
.../observability/otel/TraceLogOTelExporter.java | 447 ++++++++++++
.../observability/otel/AgentTraceSpansTest.java | 783 +++++++++++++++++++++
.../otel/TraceLogOTelExporterTest.java | 413 +++++++++++
integrations/{ => observability}/pom.xml | 23 +-
integrations/pom.xml | 2 +
11 files changed, 2670 insertions(+), 17 deletions(-)
diff --git a/docs/content/docs/operations/monitoring.md
b/docs/content/docs/operations/monitoring.md
index 75f20ba7d..812c49e38 100644
--- a/docs/content/docs/operations/monitoring.md
+++ b/docs/content/docs/operations/monitoring.md
@@ -507,3 +507,60 @@ The JSON result exposes the same graph:
| `nodes[id].upstreamEdges` | Distinct recorded upstream Event and Action
references. Consult `warnings` for missing or invalid relationships. |
| `nodes[id].actions` | Actions linked from this Event; each has a `name` and
a `children` list of output Event IDs. |
| `warnings` | Problems encountered while reading records or reconstructing
relationships, such as malformed records, conflicting Event identities, missing
upstream Events, edges without an Action name, and cycles. Cycle edges are
removed before rendering. |
+
+### Exporting to OpenTelemetry
+
+The `flink-agents-integrations-observability-otel` module converts Trace Log
records into [OpenTelemetry](https://opentelemetry.io) traces that follow the
[GenAI semantic
conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/), so agent
runs can be viewed in any OTLP-compatible backend, such as Jaeger or Grafana
Tempo.
+
+The exporter runs **out of band**: it reads Trace Log files and sends spans
over OTLP, so it has no effect on the running job and also works on logs from
finished runs. It needs execution records, which the default `EVENT_ONLY`
target does not write: use `scope: ALL` as in [Recording
Targets](#recording-targets), or add a target per type, such as `scope:
{action: "*"}` and `scope: {llm: "*"}`, for `action`, `llm`, `tool`, `parser`,
and `subagent`.
+
+#### Mapping
+
+| Trace Log | OpenTelemetry |
+|---|---|
+| Input run (`inputRunId`) | Trace, with a synthesized `invoke_agent` root
span |
+| Execution (`executionId` / `parentExecutionId`) | Span / parent span. A
child whose parent has no records, for example when targets keep Tools but not
Actions, is attached to the root span and keeps the parent id in
`flink_agents.parent_execution_id` |
+| `llm` execution | `chat {model}` span, kind CLIENT:
`gen_ai.operation.name=chat`, `gen_ai.request.model` from
`entityMetadata.model` (span name `chat` when absent), and
`gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens` from the success
record's `entityMetadata.usage`. `gen_ai.provider.name` is not set: the record
does not carry the provider |
+| `tool` execution | `execute_tool {tool}` span, kind INTERNAL:
`gen_ai.operation.name=execute_tool`, `gen_ai.tool.name`, `gen_ai.tool.call.id`
from `entityMetadata.toolCallId`, `gen_ai.tool.type` (`function` for function
tools, `extension` for remote-function and MCP tools; the raw value is always
in `flink_agents.tool.type`), and `gen_ai.agent.name` |
+| `subagent` execution | `invoke_agent {name}` span, kind INTERNAL:
`gen_ai.operation.name=invoke_agent`, `gen_ai.agent.name` from the sub-agent
name, and `gen_ai.tool.call.id` from `entityMetadata.toolCallId` |
+| `action` execution | `action {name}` span, kind INTERNAL |
+| `parser` execution | `parse {name}` span, kind INTERNAL,
`gen_ai.operation.name=parse` (a custom low-cardinality value) |
+| `businessKey` | `flink_agents.business_key`. It is the keyed-stream key,
which is a conversation only in chat-style pipelines, so it is reported as
`gen_ai.conversation.id` only when enabled
(`--business-key-as-conversation-id`, or
`setBusinessKeyAsConversationId(true)`) |
+| Failed execution | Span status `ERROR` with `attributes.errorMessage`;
`error.type` from `attributes.errorType`, else `problemCategory` |
+
+Event records are read but do not become spans. Trace and span ids are derived
deterministically from `inputRunId` and `executionId`, so exporting the same
log twice is idempotent for deduplicating backends; delivery itself is
at-least-once. The framework ids are always attached as `flink_agents.*`
attributes for correlation with the raw Trace Log. The GenAI semantic
conventions are still in development, so the exported attribute set is pinned
per Flink Agents release.
+
+**Input.** File arguments are read as streams of JSON records with
`schemaVersion: 1`, whatever their names; the `level` field and the SLF4J
`jobId`, `taskName`, and `subtaskId` fields are ignored. SLF4J output must come
from an appender that writes plain JSON, such as the automatic
`{log.file}.trace-log.log`. A directory argument is expanded to the
`traces-*.log` and `*.trace-log.log` files it contains, as in the Trace Tree
reader. Reading every subtask's file is the caller's responsibi [...]
+
+**Incomplete executions.** An execution with a start record but no terminal
record is still exported, as a zero-duration span with status `UNSET` and the
`flink_agents.execution.incomplete` attribute, and the summary reports an
`INCOMPLETE_EXECUTION` diagnostic (`MISSING_START` for the reverse case). A
crash, a dropped best-effort write, and recovery cannot be told apart
afterwards. A `reused` execution is a single record by design and becomes a
zero-duration span with `flink_agents.exec [...]
+
+**Delivery.** Spans are sent in OTLP requests of at most 512 spans
(`--batch-size`, or `setMaxExportBatchSize`), since each request must fit the
receiver's size limit (4 MiB by default for gRPC), and each request may take up
to 30 seconds. If a request fails or times out, the export stops with an
`ExportFailedException` reporting how many spans the earlier requests
delivered; running it again is safe because the span ids are deterministic.
+
+#### Usage
+
+Command line:
+
+```bash
+java -cp <otel module jar, its runtime dependencies, and slf4j-api> \
+ org.apache.flink.agents.integrations.observability.otel.TraceLogOTelExporter
\
+ --endpoint http://localhost:4317 --protocol grpc --service-name my-agent-job
\
+ [--batch-size 512] [--business-key-as-conversation-id] \
+ /tmp/flink-agent-logs
+```
+
+The module declares `slf4j-api` as `provided`, since Flink supplies it inside
a job, so it is not on Maven's runtime classpath; without it the command fails
with `NoClassDefFoundError: org/slf4j/LoggerFactory`. Add `slf4j-api`, and an
SLF4J binding if you want the exporter's log output.
+
+Programmatic:
+
+```java
+try (TraceLogOTelExporter exporter =
+ TraceLogOTelExporter.builder()
+ .setEndpoint("http://localhost:4317")
+ .setProtocol("grpc")
+ .setServiceName("my-agent-job")
+ .build()) {
+ exporter.exportFiles(List.of(Path.of("/tmp/flink-agent-logs")));
+}
+```
+
+The exporter currently produces spans only; capturing prompt and response
content is follow-up work.
diff --git a/integrations/observability/otel/pom.xml
b/integrations/observability/otel/pom.xml
new file mode 100644
index 000000000..325fd99e5
--- /dev/null
+++ b/integrations/observability/otel/pom.xml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-agents-integrations-observability</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <relativePath>../pom.xml</relativePath>
+ </parent>
+
+ <artifactId>flink-agents-integrations-observability-otel</artifactId>
+ <name>Flink Agents : Integrations: Observability: OpenTelemetry</name>
+ <packaging>jar</packaging>
+
+ <dependencyManagement>
+ <dependencies>
+ <!-- Aligns every OpenTelemetry artifact, including transitive
ones, on one version. -->
+ <dependency>
+ <groupId>io.opentelemetry</groupId>
+ <artifactId>opentelemetry-bom</artifactId>
+ <version>${opentelemetry.version}</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-agents-api</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>io.opentelemetry</groupId>
+ <artifactId>opentelemetry-sdk-trace</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>io.opentelemetry</groupId>
+ <artifactId>opentelemetry-exporter-otlp</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-databind</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ <version>${slf4j.version}</version>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+</project>
diff --git
a/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpanData.java
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpanData.java
new file mode 100644
index 000000000..749eea635
--- /dev/null
+++
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpanData.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.observability.otel;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.SpanContext;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.sdk.common.InstrumentationScopeInfo;
+import io.opentelemetry.sdk.resources.Resource;
+import io.opentelemetry.sdk.trace.data.EventData;
+import io.opentelemetry.sdk.trace.data.LinkData;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.data.StatusData;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Immutable {@link SpanData} assembled from Trace Log records.
+ *
+ * <p>Spans are built directly as {@link SpanData} (rather than through a
{@code Tracer}) because
+ * the exporter must control the trace and span ids: they are derived
deterministically from the
+ * framework's {@code inputRunId} / {@code executionId} so that re-exports are
idempotent, and the
+ * SDK tracer does not allow supplying explicit ids.
+ */
+final class AgentTraceSpanData implements SpanData {
+
+ private final String name;
+ private final SpanKind kind;
+ private final SpanContext spanContext;
+ private final SpanContext parentSpanContext;
+ private final StatusData status;
+ private final long startEpochNanos;
+ private final long endEpochNanos;
+ private final Attributes attributes;
+ private final Resource resource;
+ private final InstrumentationScopeInfo scope;
+
+ AgentTraceSpanData(
+ String name,
+ SpanKind kind,
+ SpanContext spanContext,
+ SpanContext parentSpanContext,
+ StatusData status,
+ long startEpochNanos,
+ long endEpochNanos,
+ Attributes attributes,
+ Resource resource,
+ InstrumentationScopeInfo scope) {
+ this.name = name;
+ this.kind = kind;
+ this.spanContext = spanContext;
+ this.parentSpanContext = parentSpanContext;
+ this.status = status;
+ this.startEpochNanos = startEpochNanos;
+ this.endEpochNanos = endEpochNanos;
+ this.attributes = attributes;
+ this.resource = resource;
+ this.scope = scope;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public SpanKind getKind() {
+ return kind;
+ }
+
+ @Override
+ public SpanContext getSpanContext() {
+ return spanContext;
+ }
+
+ @Override
+ public SpanContext getParentSpanContext() {
+ return parentSpanContext;
+ }
+
+ @Override
+ public StatusData getStatus() {
+ return status;
+ }
+
+ @Override
+ public long getStartEpochNanos() {
+ return startEpochNanos;
+ }
+
+ @Override
+ public Attributes getAttributes() {
+ return attributes;
+ }
+
+ @Override
+ public List<EventData> getEvents() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List<LinkData> getLinks() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public long getEndEpochNanos() {
+ return endEpochNanos;
+ }
+
+ @Override
+ public boolean hasEnded() {
+ return true;
+ }
+
+ @Override
+ public int getTotalRecordedEvents() {
+ return 0;
+ }
+
+ @Override
+ public int getTotalRecordedLinks() {
+ return 0;
+ }
+
+ @Override
+ public int getTotalAttributeCount() {
+ return attributes.size();
+ }
+
+ @Override
+ public InstrumentationScopeInfo getInstrumentationScopeInfo() {
+ return scope;
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // SpanData still requires the deprecated
accessor.
+ public io.opentelemetry.sdk.common.InstrumentationLibraryInfo
getInstrumentationLibraryInfo() {
+ return io.opentelemetry.sdk.common.InstrumentationLibraryInfo.create(
+ scope.getName(), scope.getVersion());
+ }
+
+ @Override
+ public Resource getResource() {
+ return resource;
+ }
+}
diff --git
a/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpans.java
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpans.java
new file mode 100644
index 000000000..0edd8bede
--- /dev/null
+++
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpans.java
@@ -0,0 +1,548 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.observability.otel;
+
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import io.opentelemetry.api.trace.SpanContext;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.api.trace.TraceFlags;
+import io.opentelemetry.api.trace.TraceState;
+import io.opentelemetry.sdk.common.InstrumentationScopeInfo;
+import io.opentelemetry.sdk.resources.Resource;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.data.StatusData;
+import org.apache.flink.agents.api.trace.ExecutionReporter;
+import org.apache.flink.agents.api.trace.LLMExecutionMetadataKeys;
+import org.apache.flink.agents.api.trace.ToolExecutionMetadataKeys;
+import org.apache.flink.agents.api.trace.TraceContext;
+import org.apache.flink.agents.api.trace.TraceRecord;
+
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Assembles OpenTelemetry spans from Trace Log records, following the mapping
agreed in the Agent
+ * Trace design discussions:
+ *
+ * <ul>
+ * <li>one input run = one Trace, with a synthesized {@code invoke_agent}
root span;
+ * <li>each execution ({@code action} / {@code llm} / {@code parser} /
{@code tool}) = one span,
+ * parented via {@code parentExecutionId}, or the run root when there is
none or the parent
+ * has no records;
+ * <li>Event records are not spans; they are skipped;
+ * <li>span and trace ids are derived deterministically from the framework
ids, so re-exports are
+ * idempotent (see {@link OTelIds}).
+ * </ul>
+ *
+ * <p>Attributes follow the OpenTelemetry GenAI semantic conventions
(development stability; the
+ * targeted convention set is documented per attribute below). The
framework-native ids are always
+ * attached under {@code flink_agents.*} so backends can correlate spans with
the raw Trace Log
+ * regardless of semantic-convention evolution.
+ */
+public final class AgentTraceSpans {
+
+ // OpenTelemetry GenAI semantic convention attributes (development
stability). Keys are pinned
+ // as literals on purpose: the gen_ai conventions are still evolving, and
pinning makes the
+ // exported schema explicit and stable per flink-agents release.
+ static final AttributeKey<String> GEN_AI_OPERATION_NAME =
+ AttributeKey.stringKey("gen_ai.operation.name");
+ static final AttributeKey<String> GEN_AI_AGENT_NAME =
+ AttributeKey.stringKey("gen_ai.agent.name");
+ static final AttributeKey<String> GEN_AI_TOOL_NAME =
AttributeKey.stringKey("gen_ai.tool.name");
+ static final AttributeKey<String> GEN_AI_TOOL_CALL_ID =
+ AttributeKey.stringKey("gen_ai.tool.call.id");
+ static final AttributeKey<String> GEN_AI_TOOL_TYPE =
AttributeKey.stringKey("gen_ai.tool.type");
+ static final AttributeKey<String> GEN_AI_REQUEST_MODEL =
+ AttributeKey.stringKey("gen_ai.request.model");
+ static final AttributeKey<String> GEN_AI_CONVERSATION_ID =
+ AttributeKey.stringKey("gen_ai.conversation.id");
+ static final AttributeKey<Long> GEN_AI_USAGE_INPUT_TOKENS =
+ AttributeKey.longKey("gen_ai.usage.input_tokens");
+ static final AttributeKey<Long> GEN_AI_USAGE_OUTPUT_TOKENS =
+ AttributeKey.longKey("gen_ai.usage.output_tokens");
+ static final AttributeKey<String> ERROR_TYPE =
AttributeKey.stringKey("error.type");
+
+ // Framework-native correlation attributes.
+ static final AttributeKey<String> FA_INPUT_RUN_ID =
+ AttributeKey.stringKey("flink_agents.input_run_id");
+ static final AttributeKey<String> FA_EXECUTION_ID =
+ AttributeKey.stringKey("flink_agents.execution_id");
+ static final AttributeKey<String> FA_PARENT_EXECUTION_ID =
+ AttributeKey.stringKey("flink_agents.parent_execution_id");
+ static final AttributeKey<String> FA_ENTITY_TYPE =
+ AttributeKey.stringKey("flink_agents.entity_type");
+ static final AttributeKey<String> FA_ENTITY_NAME =
+ AttributeKey.stringKey("flink_agents.entity_name");
+ static final AttributeKey<String> FA_EXECUTION_STATUS =
+ AttributeKey.stringKey("flink_agents.execution.status");
+ static final AttributeKey<Boolean> FA_EXECUTION_INCOMPLETE =
+ AttributeKey.booleanKey("flink_agents.execution.incomplete");
+ static final AttributeKey<String> FA_TOOL_TYPE =
+ AttributeKey.stringKey("flink_agents.tool.type");
+ static final AttributeKey<String> FA_BUSINESS_KEY =
+ AttributeKey.stringKey("flink_agents.business_key");
+
+ static final String INSTRUMENTATION_SCOPE_NAME =
"org.apache.flink.agents.otel";
+
+ private final Resource resource;
+ private final InstrumentationScopeInfo scope;
+ private final boolean businessKeyAsConversationId;
+
+ public AgentTraceSpans(String serviceName) {
+ this(serviceName, false);
+ }
+
+ /**
+ * Creates an assembler.
+ *
+ * @param serviceName the {@code service.name} resource attribute
+ * @param businessKeyAsConversationId whether to also report {@code
businessKey} as {@code
+ * gen_ai.conversation.id}. The business key is the keyed-stream key,
which is a
+ * conversation only in chat-style pipelines, so this is off by
default; the key is always
+ * attached as {@code flink_agents.business_key}.
+ */
+ public AgentTraceSpans(String serviceName, boolean
businessKeyAsConversationId) {
+ this.businessKeyAsConversationId = businessKeyAsConversationId;
+ this.resource =
+ Resource.getDefault().toBuilder()
+ .put(AttributeKey.stringKey("service.name"),
serviceName)
+ .build();
+ this.scope =
InstrumentationScopeInfo.create(INSTRUMENTATION_SCOPE_NAME);
+ }
+
+ /** Assembles spans from Trace Log records; ordering of the input records
does not matter. */
+ public List<SpanData> assemble(List<TraceRecord> records) {
+ return assemble(records, new ArrayList<>());
+ }
+
+ /**
+ * Assembles spans and appends machine-readable {@link
ConverterDiagnostic}s (incomplete
+ * executions, terminal records without a start, parents without records,
records with an
+ * unparseable timestamp) to the given collector.
+ */
+ public List<SpanData> assemble(
+ List<TraceRecord> records, List<ConverterDiagnostic> diagnostics) {
+ // executionId -> collected lifecycle records; LinkedHashMap keeps
output ordering stable.
+ Map<String, ExecutionSpanBuilder> executions = new LinkedHashMap<>();
+ Map<String, RunAccumulator> runs = new LinkedHashMap<>();
+
+ for (TraceRecord record : records) {
+ TraceContext context = record.getContext();
+ // Event records and execution records without a run or a status
are not spans.
+ if (TraceContext.EVENT_ENTITY_TYPE.equals(context.getEntityType())
+ || context.getExecutionId() == null
+ || context.getInputRunId() == null
+ || !isKnownStatus(record.getStatus())) {
+ continue;
+ }
+ if (!isValidTimestamp(record.getTimestamp())) {
+ // Skip just this record, as for an undecodable one, rather
than failing the run.
+ diagnostics.add(
+ new ConverterDiagnostic(
+ ConverterDiagnostic.MALFORMED_RECORD,
+ context.getExecutionId(),
+ "Invalid timestamp '"
+ + record.getTimestamp()
+ + "'; the record was skipped.",
+ null));
+ continue;
+ }
+ executions
+ .computeIfAbsent(context.getExecutionId(), id -> new
ExecutionSpanBuilder())
+ .accept(record);
+ runs.computeIfAbsent(context.getInputRunId(), id -> new
RunAccumulator())
+ .accept(record);
+ }
+
+ List<SpanData> spans = new ArrayList<>(executions.size() +
runs.size());
+ for (Map.Entry<String, RunAccumulator> run : runs.entrySet()) {
+ spans.add(buildRunRootSpan(run.getKey(), run.getValue()));
+ }
+ for (ExecutionSpanBuilder execution : executions.values()) {
+ spans.add(buildExecutionSpan(execution, executions.keySet(),
diagnostics));
+ }
+ return spans;
+ }
+
+ private SpanData buildRunRootSpan(String inputRunId, RunAccumulator run) {
+ AttributesBuilder attributes = Attributes.builder();
+ attributes.put(GEN_AI_OPERATION_NAME, "invoke_agent");
+ attributes.put(FA_INPUT_RUN_ID, inputRunId);
+ if (run.agentName != null) {
+ attributes.put(GEN_AI_AGENT_NAME, run.agentName);
+ }
+ putBusinessKey(attributes, run.businessKey);
+ String name = run.agentName != null ? "invoke_agent " + run.agentName
: "invoke_agent";
+ return new AgentTraceSpanData(
+ name,
+ SpanKind.INTERNAL,
+ spanContext(inputRunId, OTelIds.runRootSpanId(inputRunId)),
+ SpanContext.getInvalid(),
+ StatusData.unset(),
+ run.minEpochNanos,
+ run.maxEpochNanos,
+ attributes.build(),
+ resource,
+ scope);
+ }
+
+ private SpanData buildExecutionSpan(
+ ExecutionSpanBuilder execution,
+ Set<String> executionIds,
+ List<ConverterDiagnostic> diagnostics) {
+ TraceContext any = execution.anyRecord().getContext();
+ String inputRunId = any.getInputRunId();
+ String entityType = any.getEntityType();
+ String entityName = any.getEntityName();
+
+ AttributesBuilder attributes = Attributes.builder();
+ attributes.put(FA_INPUT_RUN_ID, inputRunId);
+ attributes.put(FA_EXECUTION_ID, any.getExecutionId());
+ attributes.put(FA_ENTITY_TYPE, entityType);
+ attributes.put(FA_ENTITY_NAME, entityName);
+ putBusinessKey(attributes, any.getBusinessKey());
+
+ String name;
+ SpanKind kind;
+ Map<String, Object> metadata = any.getEntityMetadata();
+ if (ExecutionReporter.EntityTypes.LLM.equals(entityType)) {
+ // The entity name is the chat model resource; the requested model
id travels in
+ // entityMetadata. gen_ai.provider.name stays unset: the record
does not carry it.
+ String model = stringValue(metadata,
LLMExecutionMetadataKeys.MODEL);
+ name = model != null ? "chat " + model : "chat";
+ kind = SpanKind.CLIENT;
+ attributes.put(GEN_AI_OPERATION_NAME, "chat");
+ if (model != null) {
+ attributes.put(GEN_AI_REQUEST_MODEL, model);
+ }
+ } else if (ExecutionReporter.EntityTypes.TOOL.equals(entityType)) {
+ name = "execute_tool " + entityName;
+ // INTERNAL, as the GenAI conventions specify for execute_tool:
the span measures
+ // the framework running the tool, whatever transport the tool
itself uses.
+ kind = SpanKind.INTERNAL;
+ attributes.put(GEN_AI_OPERATION_NAME, "execute_tool");
+ attributes.put(GEN_AI_TOOL_NAME, entityName);
+ String agentName = execution.agentName();
+ if (agentName != null) {
+ attributes.put(GEN_AI_AGENT_NAME, agentName);
+ }
+ // The call id the model's tool-call request carries.
+ String callId = stringValue(metadata,
ToolExecutionMetadataKeys.TOOL_CALL_ID);
+ if (callId != null) {
+ attributes.put(GEN_AI_TOOL_CALL_ID, callId);
+ }
+ String toolType = stringValue(metadata,
ToolExecutionMetadataKeys.TOOL_TYPE);
+ if (toolType != null) {
+ attributes.put(FA_TOOL_TYPE, toolType);
+ String conventionType = conventionToolType(toolType);
+ if (conventionType != null) {
+ attributes.put(GEN_AI_TOOL_TYPE, conventionType);
+ }
+ }
+ } else if (ExecutionReporter.EntityTypes.SUBAGENT.equals(entityType)) {
+ // A sub-agent delegation the model requested as a tool call.
Records don't
+ // distinguish in-process agents from remote async jobs, so this
keeps the
+ // fallback's INTERNAL kind until they carry locality.
+ name = "invoke_agent " + entityName;
+ kind = SpanKind.INTERNAL;
+ attributes.put(GEN_AI_OPERATION_NAME, "invoke_agent");
+ attributes.put(GEN_AI_AGENT_NAME, entityName);
+ String callId = stringValue(metadata,
ToolExecutionMetadataKeys.TOOL_CALL_ID);
+ if (callId != null) {
+ attributes.put(GEN_AI_TOOL_CALL_ID, callId);
+ }
+ } else if (ExecutionReporter.EntityTypes.ACTION.equals(entityType)) {
+ name = "action " + entityName;
+ kind = SpanKind.INTERNAL;
+ } else if (ExecutionReporter.EntityTypes.PARSER.equals(entityType)) {
+ name = "parse " + entityName;
+ kind = SpanKind.INTERNAL;
+ // A low-cardinality custom value: the GenAI conventions permit
custom operation
+ // names when no well-known value applies (parser has none).
+ attributes.put(GEN_AI_OPERATION_NAME, "parse");
+ } else {
+ name = entityType + " " + entityName;
+ kind = SpanKind.INTERNAL;
+ }
+
+ StatusData status = StatusData.unset();
+ TraceRecord terminal = execution.terminal;
+ if (terminal != null) {
+ if (TraceRecord.Statuses.FAILED.equals(terminal.getStatus())) {
+ String errorMessage = attributeString(terminal,
"errorMessage");
+ status =
+ StatusData.create(
+ StatusCode.ERROR, errorMessage != null ?
errorMessage : "");
+ String errorType = attributeString(terminal, "errorType");
+ if (errorType == null) {
+ errorType = terminal.getProblemCategory();
+ }
+ if (errorType != null) {
+ attributes.put(ERROR_TYPE, errorType);
+ }
+ }
+ attributes.put(FA_EXECUTION_STATUS, terminal.getStatus());
+ // Usage travels on the success record's metadata, which can
differ from the start
+ // record's metadata for the same execution.
+ putUsageIfPresent(
+ attributes,
+
terminal.getContext().getEntityMetadata().get(LLMExecutionMetadataKeys.USAGE));
+ }
+ boolean reused =
+ terminal != null &&
TraceRecord.Statuses.REUSED.equals(terminal.getStatus());
+ if (terminal == null) {
+ // A start with no terminal: crash, a best-effort write that
dropped the terminal
+ // record, or recovery discarding the transient pairing —
indistinguishable here, so
+ // the span keeps status UNSET and carries an explicit marker
instead of ERROR.
+ attributes.put(FA_EXECUTION_INCOMPLETE, true);
+ diagnostics.add(
+ new ConverterDiagnostic(
+ ConverterDiagnostic.INCOMPLETE_EXECUTION,
+ any.getExecutionId(),
+ "Execution has a start record but no terminal
record; exported as a"
+ + " zero-duration span with status UNSET.",
+ null));
+ } else if (execution.start() == null && !reused) {
+ // A terminal with no start (reused executions are single-record
by design).
+ attributes.put(FA_EXECUTION_INCOMPLETE, true);
+ diagnostics.add(
+ new ConverterDiagnostic(
+ ConverterDiagnostic.MISSING_START,
+ any.getExecutionId(),
+ "Execution has a terminal record but no start
record; exported as a"
+ + " zero-duration span at the terminal
timestamp.",
+ null));
+ }
+
+ TraceRecord start = execution.start();
+ long startNanos =
+ start != null
+ ? epochNanos(start.getTimestamp())
+ : epochNanos(terminal.getTimestamp());
+ long endNanos = terminal != null ? epochNanos(terminal.getTimestamp())
: startNanos;
+
+ String parentId = any.getParentExecutionId();
+ SpanContext parent;
+ if (parentId != null && executionIds.contains(parentId)) {
+ parent = spanContext(inputRunId, OTelIds.spanId(parentId));
+ } else {
+ parent = spanContext(inputRunId,
OTelIds.runRootSpanId(inputRunId));
+ if (parentId != null) {
+ // The parent has no records, e.g. Trace Log targets that keep
tool calls but not
+ // Actions; attach to the run root rather than to a span that
never arrives.
+ attributes.put(FA_PARENT_EXECUTION_ID, parentId);
+ diagnostics.add(
+ new ConverterDiagnostic(
+ ConverterDiagnostic.MISSING_PARENT,
+ any.getExecutionId(),
+ "Parent execution "
+ + parentId
+ + " has no records; the span was
attached to the run"
+ + " root.",
+ null));
+ }
+ }
+
+ return new AgentTraceSpanData(
+ name,
+ kind,
+ spanContext(inputRunId, OTelIds.spanId(any.getExecutionId())),
+ parent,
+ status,
+ startNanos,
+ Math.max(endNanos, startNanos),
+ attributes.build(),
+ resource,
+ scope);
+ }
+
+ /**
+ * Maps the framework tool type onto the GenAI well-known {@code
gen_ai.tool.type} values: a
+ * function the agent runs itself is {@code function}; remote functions
and MCP tools call out
+ * to external systems, which is {@code extension}. Model built-in tools
have no well-known
+ * counterpart and keep only the raw {@code flink_agents.tool.type}.
+ */
+ private static String conventionToolType(String toolType) {
+ switch (toolType) {
+ case "function":
+ return "function";
+ case "remote_function":
+ case "mcp":
+ return "extension";
+ default:
+ return null;
+ }
+ }
+
+ private void putBusinessKey(AttributesBuilder attributes, String
businessKey) {
+ if (businessKey == null) {
+ return;
+ }
+ attributes.put(FA_BUSINESS_KEY, businessKey);
+ if (businessKeyAsConversationId) {
+ attributes.put(GEN_AI_CONVERSATION_ID, businessKey);
+ }
+ }
+
+ private static String stringValue(Map<String, Object> map, String key) {
+ Object value = map.get(key);
+ return value == null ? null : String.valueOf(value);
+ }
+
+ /**
+ * A string attribute of the record. At {@code STANDARD} level the Trace
Log replaces a long
+ * string with {@code {"truncatedString": "<prefix>...", "omittedChars":
N}}; the prefix is
+ * kept.
+ */
+ private static String attributeString(TraceRecord record, String name) {
+ Object value = record.getAttributes().get(name);
+ if (value instanceof Map) {
+ value = ((Map<?, ?>) value).get("truncatedString");
+ }
+ return value instanceof String ? (String) value : null;
+ }
+
+ private static void putUsageIfPresent(AttributesBuilder attributes, Object
usage) {
+ Map<?, ?> tokens = usage instanceof Map ? (Map<?, ?>) usage :
Collections.emptyMap();
+ Object prompt = tokens.get("promptTokens");
+ if (prompt instanceof Number) {
+ attributes.put(GEN_AI_USAGE_INPUT_TOKENS, ((Number)
prompt).longValue());
+ }
+ Object completion = tokens.get("completionTokens");
+ if (completion instanceof Number) {
+ attributes.put(GEN_AI_USAGE_OUTPUT_TOKENS, ((Number)
completion).longValue());
+ }
+ }
+
+ private static boolean isKnownStatus(String status) {
+ return TraceRecord.Statuses.CREATED.equals(status)
+ || TraceRecord.Statuses.STARTED.equals(status)
+ || TraceRecord.Statuses.SUCCESS.equals(status)
+ || TraceRecord.Statuses.FAILED.equals(status)
+ || TraceRecord.Statuses.REUSED.equals(status);
+ }
+
+ private static SpanContext spanContext(String inputRunId, String
spanIdHex) {
+ return SpanContext.create(
+ OTelIds.traceId(inputRunId),
+ spanIdHex,
+ TraceFlags.getSampled(),
+ TraceState.getDefault());
+ }
+
+ private static boolean isValidTimestamp(String isoTimestamp) {
+ try {
+ Instant.parse(isoTimestamp);
+ return true;
+ } catch (DateTimeParseException e) {
+ return false;
+ }
+ }
+
+ private static long epochNanos(String isoTimestamp) {
+ Instant instant = Instant.parse(isoTimestamp);
+ return TimeUnit.SECONDS.toNanos(instant.getEpochSecond()) +
instant.getNano();
+ }
+
+ /**
+ * Created/started/terminal records for one execution id. When the Trace
Log repeats a record
+ * (outputs are best-effort and may duplicate), the earliest one of each
kind is kept, so the
+ * result does not depend on record order.
+ */
+ private static final class ExecutionSpanBuilder {
+ private TraceRecord created;
+ private TraceRecord started;
+ private TraceRecord terminal;
+
+ void accept(TraceRecord record) {
+ String status = record.getStatus();
+ if (TraceRecord.Statuses.CREATED.equals(status)) {
+ created = earlier(created, record);
+ } else if (TraceRecord.Statuses.STARTED.equals(status)) {
+ started = earlier(started, record);
+ } else {
+ // success / failed / reused all terminate the execution. A
reused execution has
+ // no started record: it becomes a point-in-time span with
status "reused".
+ terminal = earlier(terminal, record);
+ }
+ }
+
+ private static TraceRecord earlier(TraceRecord current, TraceRecord
candidate) {
+ return current == null
+ || epochNanos(candidate.getTimestamp())
+ < epochNanos(current.getTimestamp())
+ ? candidate
+ : current;
+ }
+
+ /**
+ * Where the span starts: the started record, else the created record.
A created record
+ * without a started one is a Tool that was queued but never reported
starting, including
+ * one whose preparation failed; the created record is never a
terminal.
+ */
+ TraceRecord start() {
+ return started != null ? started : created;
+ }
+
+ TraceRecord anyRecord() {
+ TraceRecord start = start();
+ return start != null ? start : terminal;
+ }
+
+ /** The agent name from whichever of this execution's records carries
one. */
+ String agentName() {
+ for (TraceRecord record : new TraceRecord[] {started, created,
terminal}) {
+ if (record != null && record.getContext().getAgentName() !=
null) {
+ return record.getContext().getAgentName();
+ }
+ }
+ return null;
+ }
+ }
+
+ /** Aggregated run-level facts for the synthesized root span. */
+ private static final class RunAccumulator {
+ private long minEpochNanos = Long.MAX_VALUE;
+ private long maxEpochNanos = Long.MIN_VALUE;
+ private String agentName;
+ private String businessKey;
+
+ void accept(TraceRecord record) {
+ long nanos = epochNanos(record.getTimestamp());
+ minEpochNanos = Math.min(minEpochNanos, nanos);
+ maxEpochNanos = Math.max(maxEpochNanos, nanos);
+ TraceContext context = record.getContext();
+ if (agentName == null && context.getAgentName() != null) {
+ agentName = context.getAgentName();
+ }
+ if (businessKey == null && context.getBusinessKey() != null) {
+ businessKey = context.getBusinessKey();
+ }
+ }
+ }
+}
diff --git
a/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/ConverterDiagnostic.java
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/ConverterDiagnostic.java
new file mode 100644
index 000000000..50e6d6b66
--- /dev/null
+++
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/ConverterDiagnostic.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.observability.otel;
+
+import java.util.Objects;
+
+/**
+ * One machine-readable converter diagnostic, mirroring the warning model of
{@code trace_tree.py}
+ * ({@code code} + id + {@code message} + optional file location) so the two
Trace Log tools share
+ * one diagnostic vocabulary.
+ *
+ * <p>Codes emitted by this converter:
+ *
+ * <ul>
+ * <li>{@link #MALFORMED_RECORD}: an input record could not be parsed, or
its timestamp could not;
+ * it was skipped.
+ * <li>{@link #INCOMPLETE_EXECUTION}: a start record has no terminal record.
The span is still
+ * exported, closed at the observed start timestamp (zero duration) with
status UNSET and the
+ * {@code flink_agents.execution.incomplete} attribute — a missing
terminal cannot be
+ * distinguished between a crash, a best-effort Trace Log write that
dropped the terminal
+ * record, and recovery discarding the transient start/terminal pairing.
+ * <li>{@link #MISSING_START}: a terminal record has no start record (same
export policy).
+ * <li>{@link #MISSING_PARENT}: the parent execution has no records, for
example when the Trace
+ * Log targets keep calls but not their Actions. The span is attached to
the run root and
+ * keeps the parent id in {@code flink_agents.parent_execution_id}.
+ * </ul>
+ */
+public final class ConverterDiagnostic {
+
+ public static final String MALFORMED_RECORD = "MALFORMED_RECORD";
+ public static final String INCOMPLETE_EXECUTION = "INCOMPLETE_EXECUTION";
+ public static final String MISSING_START = "MISSING_START";
+ public static final String MISSING_PARENT = "MISSING_PARENT";
+
+ private final String code;
+ private final String executionId;
+ private final String message;
+ private final String filePath;
+
+ ConverterDiagnostic(String code, String executionId, String message,
String filePath) {
+ this.code = Objects.requireNonNull(code);
+ this.executionId = executionId;
+ this.message = Objects.requireNonNull(message);
+ this.filePath = filePath;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public String getExecutionId() {
+ return executionId;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public String getFilePath() {
+ return filePath;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder(code);
+ if (executionId != null) {
+ sb.append(" [executionId=").append(executionId).append(']');
+ }
+ if (filePath != null) {
+ sb.append(" [file=").append(filePath).append(']');
+ }
+ return sb.append(": ").append(message).toString();
+ }
+}
diff --git
a/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/OTelIds.java
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/OTelIds.java
new file mode 100644
index 000000000..1e6358817
--- /dev/null
+++
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/OTelIds.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.observability.otel;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Deterministic derivation of fixed-width OpenTelemetry ids from the opaque
framework ids.
+ *
+ * <p>Per the design in the Agent Trace discussions, the framework keeps
{@code inputRunId} and
+ * {@code executionId} opaque; the exporter derives valid 128-bit trace ids
and 64-bit span ids by
+ * hashing them. Determinism makes re-exports idempotent: exporting the same
Trace Log twice
+ * produces byte-identical ids, so an OTLP backend deduplicates instead of
double-counting.
+ */
+final class OTelIds {
+
+ /** Domain separator so a run-root span id can never collide with an
execution span id. */
+ private static final String RUN_SPAN_DOMAIN = "/input-run-root";
+
+ private OTelIds() {}
+
+ /** Derives the 32-hex-char OTel trace id for an input run. */
+ static String traceId(String inputRunId) {
+ return hexDigest(inputRunId, 16);
+ }
+
+ /** Derives the 16-hex-char OTel span id for an execution. */
+ static String spanId(String executionId) {
+ return hexDigest(executionId, 8);
+ }
+
+ /** Derives the 16-hex-char OTel span id for the synthesized run-root
span. */
+ static String runRootSpanId(String inputRunId) {
+ return hexDigest(inputRunId + RUN_SPAN_DOMAIN, 8);
+ }
+
+ private static String hexDigest(String value, int bytes) {
+ byte[] digest = sha256(value.getBytes(StandardCharsets.UTF_8));
+ StringBuilder hex = new StringBuilder(bytes * 2);
+ boolean allZero = true;
+ for (int i = 0; i < bytes; i++) {
+ if (digest[i] != 0) {
+ allZero = false;
+ }
+ }
+ // An all-zero id is invalid in OTel. Astronomically unlikely from
SHA-256, but cheap to
+ // guard: flip the last byte so the id stays deterministic and valid.
+ if (allZero) {
+ digest[bytes - 1] = 1;
+ }
+ for (int i = 0; i < bytes; i++) {
+ hex.append(Character.forDigit((digest[i] >> 4) & 0xF, 16));
+ hex.append(Character.forDigit(digest[i] & 0xF, 16));
+ }
+ return hex.toString();
+ }
+
+ private static byte[] sha256(byte[] input) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(input);
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is mandatory on every JVM.
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+}
diff --git
a/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/TraceLogOTelExporter.java
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/TraceLogOTelExporter.java
new file mode 100644
index 000000000..a4406d319
--- /dev/null
+++
b/integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/TraceLogOTelExporter.java
@@ -0,0 +1,447 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.observability.otel;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.MappingIterator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
+import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
+import io.opentelemetry.sdk.common.CompletableResultCode;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.export.SpanExporter;
+import org.apache.flink.agents.api.trace.TraceRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.TreeSet;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Exports Trace Logs as OpenTelemetry GenAI traces.
+ *
+ * <p>This is the out-of-band exporter agreed in the Agent Trace design
discussions: it reads the
+ * Trace Log written by the File or SLF4J trace logger, assembles spans per
{@link AgentTraceSpans},
+ * and pushes them to any OTLP endpoint. The Trace Log must keep execution
records ({@code
+ * trace-log.targets} other than the default {@code EVENT_ONLY}). Running out
of band means zero
+ * impact on the Flink job, and it works retroactively on logs from already
finished runs. Because
+ * span/trace ids are derived deterministically from the framework ids,
exporting the same log twice
+ * is idempotent on the backend.
+ *
+ * <p>Input files are streams of JSON objects — the JSONL written by the File
trace logger, and also
+ * a pretty-printed variant (the parser consumes concatenated JSON objects
regardless of line
+ * breaks).
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * TraceLogOTelExporter exporter =
+ * TraceLogOTelExporter.builder()
+ * .setEndpoint("http://localhost:4317")
+ * .setProtocol("grpc")
+ * .setServiceName("my-agent-job")
+ * .build();
+ *
exporter.exportFiles(List.of(Path.of("/tmp/flink-agents/traces-<jobId>-<task>-0.log")));
+ * exporter.shutdown();
+ * }</pre>
+ */
+public class TraceLogOTelExporter implements AutoCloseable {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(TraceLogOTelExporter.class);
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** Per-file cap on malformed-record diagnostics, guarding against a stuck
parser. */
+ private static final int MAX_MALFORMED_PER_FILE = 1000;
+
+ /** Default spans per OTLP request, matching the SDK {@code
BatchSpanProcessor} default. */
+ static final int DEFAULT_MAX_EXPORT_BATCH_SIZE = 512;
+
+ /** How long each OTLP request may take before the export is reported as
failed. */
+ private static final long BATCH_EXPORT_TIMEOUT_SECONDS = 30;
+
+ private final AgentTraceSpans assembler;
+ private final SpanExporter spanExporter;
+ private final int maxExportBatchSize;
+
+ TraceLogOTelExporter(
+ AgentTraceSpans assembler, SpanExporter spanExporter, int
maxExportBatchSize) {
+ this.assembler = assembler;
+ this.spanExporter = spanExporter;
+ this.maxExportBatchSize = maxExportBatchSize;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Reads the given Trace Log files and exports the assembled spans.
+ *
+ * <p>A directory argument is expanded to the {@code traces-*.log} and
{@code *.trace-log.log}
+ * files it contains (the File trace logger naming contract, matching
{@code trace_tree.py}); a
+ * file argument is consumed as-is regardless of its name, so records
collected from any Trace
+ * Log sink work once they are on disk. Completeness of a multi-subtask
file set is the caller's
+ * responsibility under the batch contract; the summary reports what was
read.
+ */
+ public ExportSummary exportFiles(List<Path> files) throws IOException {
+ List<TraceRecord> records = new ArrayList<>();
+ List<ConverterDiagnostic> diagnostics = new ArrayList<>();
+ for (Path file : expandDirectories(files)) {
+ try (InputStream in = Files.newInputStream(file)) {
+ MappingIterator<TraceRecord> it;
+ try {
+ it = MAPPER.readerFor(TraceRecord.class).readValues(in);
+ } catch (JsonProcessingException e) {
+ // The stream is broken from its first token: skip this
file, keep the others.
+ diagnostics.add(malformed(e, file));
+ continue;
+ }
+ readRecords(it, file, records, diagnostics);
+ }
+ }
+ return export(records, diagnostics);
+ }
+
+ private static void readRecords(
+ MappingIterator<TraceRecord> it,
+ Path file,
+ List<TraceRecord> records,
+ List<ConverterDiagnostic> diagnostics)
+ throws IOException {
+ try (it) {
+ int malformedInFile = 0;
+ boolean fileAborted = false;
+ while (!fileAborted) {
+ try {
+ if (!it.hasNext()) {
+ break;
+ }
+ records.add(it.next());
+ } catch (RuntimeException e) {
+ // A malformed record must not abort the whole export.
Value-level errors
+ // allow the iterator to continue; a structurally broken
stream (or a
+ // parser that stops making progress) aborts just this
file, with the
+ // diagnostic naming it either way.
+ diagnostics.add(malformed(e, file));
+ malformedInFile++;
+ fileAborted = malformedInFile >= MAX_MALFORMED_PER_FILE ||
!canContinue(it);
+ }
+ }
+ }
+ }
+
+ private static ConverterDiagnostic malformed(Exception e, Path file) {
+ return new ConverterDiagnostic(
+ ConverterDiagnostic.MALFORMED_RECORD,
+ null,
+ "Could not decode a Trace Log record: " + e.getMessage(),
+ file.toString());
+ }
+
+ private static boolean canContinue(MappingIterator<TraceRecord> it) {
+ try {
+ // Probe the iterator: a value-level bind error leaves it usable,
a structurally
+ // broken stream makes hasNext() itself throw.
+ it.hasNext();
+ return true;
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
+ private static List<Path> expandDirectories(List<Path> paths) throws
IOException {
+ List<Path> expanded = new ArrayList<>();
+ for (Path path : paths) {
+ if (Files.isDirectory(path)) {
+ TreeSet<Path> discovered = new TreeSet<>();
+ for (String glob : new String[] {"traces-*.log",
"*.trace-log.log"}) {
+ try (DirectoryStream<Path> stream =
Files.newDirectoryStream(path, glob)) {
+ stream.forEach(discovered::add);
+ }
+ }
+ expanded.addAll(discovered);
+ } else {
+ expanded.add(path);
+ }
+ }
+ return expanded;
+ }
+
+ /** Assembles and exports spans from already-parsed records. */
+ public ExportSummary exportRecords(List<TraceRecord> records) {
+ return export(records, new ArrayList<>());
+ }
+
+ private ExportSummary export(List<TraceRecord> records,
List<ConverterDiagnostic> diagnostics) {
+ List<SpanData> spans = assembler.assemble(records, diagnostics);
+ for (ConverterDiagnostic diagnostic : diagnostics) {
+ LOG.warn("{}", diagnostic);
+ }
+ // Each OTLP exporter marshals a whole collection into one request,
and receivers cap the
+ // request size (4 MiB by default for gRPC), so the spans go out in
bounded batches.
+ int exported = 0;
+ while (exported < spans.size()) {
+ List<SpanData> batch =
+ spans.subList(exported, Math.min(exported +
maxExportBatchSize, spans.size()));
+ CompletableResultCode result = spanExporter.export(batch);
+ result.join(BATCH_EXPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ if (!result.isSuccess()) {
+ throw new ExportFailedException(exported, spans.size());
+ }
+ exported += batch.size();
+ }
+ return new ExportSummary(records.size(), exported, diagnostics);
+ }
+
+ @Override
+ public void close() {
+ shutdown();
+ }
+
+ /** Flushes and shuts down the underlying OTLP exporter. */
+ public void shutdown() {
+ spanExporter.flush().join(10, TimeUnit.SECONDS);
+ spanExporter.shutdown().join(10, TimeUnit.SECONDS);
+ }
+
+ /**
+ * Thrown when an OTLP request fails or times out. Earlier batches have
already been delivered;
+ * re-running the export is safe because span and trace ids are
deterministic.
+ */
+ public static final class ExportFailedException extends
IllegalStateException {
+ private static final long serialVersionUID = 1L;
+
+ private final int spansExported;
+ private final int spansTotal;
+
+ ExportFailedException(int spansExported, int spansTotal) {
+ super(
+ "OTLP export did not complete successfully: exported "
+ + spansExported
+ + " of "
+ + spansTotal
+ + " spans before a batch failed.");
+ this.spansExported = spansExported;
+ this.spansTotal = spansTotal;
+ }
+
+ /** Spans delivered by the batches that completed before the failure.
*/
+ public int getSpansExported() {
+ return spansExported;
+ }
+
+ public int getSpansTotal() {
+ return spansTotal;
+ }
+ }
+
+ /** Counters and diagnostics describing one export invocation. */
+ public static final class ExportSummary {
+ private final int recordsRead;
+ private final int spansExported;
+ private final List<ConverterDiagnostic> diagnostics;
+
+ ExportSummary(int recordsRead, int spansExported,
List<ConverterDiagnostic> diagnostics) {
+ this.recordsRead = recordsRead;
+ this.spansExported = spansExported;
+ this.diagnostics = Collections.unmodifiableList(new
ArrayList<>(diagnostics));
+ }
+
+ public int getRecordsRead() {
+ return recordsRead;
+ }
+
+ public int getSpansExported() {
+ return spansExported;
+ }
+
+ /** Machine-readable diagnostics, per the {@code trace_tree.py}
warning model. */
+ public List<ConverterDiagnostic> getDiagnostics() {
+ return diagnostics;
+ }
+
+ @Override
+ public String toString() {
+ return "read "
+ + recordsRead
+ + " records, exported "
+ + spansExported
+ + " spans, "
+ + diagnostics.size()
+ + " diagnostics";
+ }
+ }
+
+ /** Builder for {@link TraceLogOTelExporter}. */
+ public static final class Builder {
+ private String endpoint = "http://localhost:4317";
+ private String protocol = "grpc";
+ private String serviceName = "flink-agents";
+ private int maxExportBatchSize = DEFAULT_MAX_EXPORT_BATCH_SIZE;
+ private boolean businessKeyAsConversationId;
+ private SpanExporter spanExporter;
+
+ /** OTLP endpoint, e.g. {@code http://localhost:4317} (grpc) or {@code
.../v1/traces}. */
+ public Builder setEndpoint(String endpoint) {
+ this.endpoint = endpoint;
+ return this;
+ }
+
+ /** OTLP transport protocol: {@code grpc} (default) or {@code
http/protobuf}. */
+ public Builder setProtocol(String protocol) {
+ this.protocol = protocol;
+ return this;
+ }
+
+ /** Value of the {@code service.name} resource attribute; defaults to
"flink-agents". */
+ public Builder setServiceName(String serviceName) {
+ this.serviceName = serviceName;
+ return this;
+ }
+
+ /** Maximum spans per OTLP request; defaults to 512. */
+ public Builder setMaxExportBatchSize(int maxExportBatchSize) {
+ if (maxExportBatchSize <= 0) {
+ throw new IllegalArgumentException(
+ "The export batch size must be positive, got " +
maxExportBatchSize + ".");
+ }
+ this.maxExportBatchSize = maxExportBatchSize;
+ return this;
+ }
+
+ /**
+ * Whether to also report {@code businessKey} as {@code
gen_ai.conversation.id}, for
+ * pipelines keyed by conversation. Off by default; the key is always
exported as {@code
+ * flink_agents.business_key}.
+ */
+ public Builder setBusinessKeyAsConversationId(boolean
businessKeyAsConversationId) {
+ this.businessKeyAsConversationId = businessKeyAsConversationId;
+ return this;
+ }
+
+ /** Overrides the underlying exporter; intended for tests. */
+ public Builder setSpanExporter(SpanExporter spanExporter) {
+ this.spanExporter = spanExporter;
+ return this;
+ }
+
+ public TraceLogOTelExporter build() {
+ SpanExporter exporter = spanExporter;
+ if (exporter == null) {
+ if ("grpc".equalsIgnoreCase(protocol)) {
+ exporter =
OtlpGrpcSpanExporter.builder().setEndpoint(endpoint).build();
+ } else if ("http/protobuf".equalsIgnoreCase(protocol)
+ || "http".equalsIgnoreCase(protocol)) {
+ exporter =
OtlpHttpSpanExporter.builder().setEndpoint(endpoint).build();
+ } else {
+ throw new IllegalArgumentException(
+ "Unsupported OTLP protocol: "
+ + protocol
+ + " (expected 'grpc' or
'http/protobuf').");
+ }
+ }
+ return new TraceLogOTelExporter(
+ new AgentTraceSpans(serviceName,
businessKeyAsConversationId),
+ exporter,
+ maxExportBatchSize);
+ }
+ }
+
+ /**
+ * Command-line entry point.
+ *
+ * <p>Usage: {@code TraceLogOTelExporter [--endpoint URL] [--protocol
grpc|http/protobuf]
+ * [--service-name NAME] [--batch-size N]
[--business-key-as-conversation-id]
+ * traceLogFileOrDir...}
+ */
+ public static void main(String[] args) throws IOException {
+ Builder builder = builder();
+ List<Path> files = new ArrayList<>();
+ for (int i = 0; i < args.length; i++) {
+ String arg = args[i];
+ boolean isFlag =
+ arg.equals("--endpoint")
+ || arg.equals("--protocol")
+ || arg.equals("--service-name")
+ || arg.equals("--batch-size");
+ if (isFlag && i + 1 >= args.length) {
+ exitWithUsage("Missing value for " + arg + ".");
+ }
+ switch (arg) {
+ case "--endpoint":
+ builder.setEndpoint(args[++i]);
+ break;
+ case "--protocol":
+ builder.setProtocol(args[++i]);
+ break;
+ case "--service-name":
+ builder.setServiceName(args[++i]);
+ break;
+ case "--batch-size":
+ builder.setMaxExportBatchSize(parseBatchSize(args[++i]));
+ break;
+ case "--business-key-as-conversation-id":
+ builder.setBusinessKeyAsConversationId(true);
+ break;
+ default:
+ files.add(Path.of(arg));
+ }
+ }
+ if (files.isEmpty()) {
+ exitWithUsage("No Trace Log file or directory given.");
+ }
+ try (TraceLogOTelExporter exporter = builder.build()) {
+ ExportSummary summary = exporter.exportFiles(files);
+ System.out.println(summary);
+ for (ConverterDiagnostic diagnostic : summary.getDiagnostics()) {
+ System.err.println(diagnostic);
+ }
+ }
+ }
+
+ private static int parseBatchSize(String value) {
+ try {
+ int batchSize = Integer.parseInt(value);
+ if (batchSize > 0) {
+ return batchSize;
+ }
+ } catch (NumberFormatException e) {
+ // Reported below.
+ }
+ exitWithUsage("--batch-size needs a positive integer, got '" + value +
"'.");
+ return DEFAULT_MAX_EXPORT_BATCH_SIZE;
+ }
+
+ private static void exitWithUsage(String problem) {
+ System.err.println(problem);
+ System.err.println(
+ "Usage: TraceLogOTelExporter [--endpoint URL] [--protocol
grpc|http/protobuf]"
+ + " [--service-name NAME] [--batch-size N]"
+ + " [--business-key-as-conversation-id]
traceLogFileOrDir...");
+ System.exit(2);
+ }
+}
diff --git
a/integrations/observability/otel/src/test/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpansTest.java
b/integrations/observability/otel/src/test/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpansTest.java
new file mode 100644
index 000000000..e3991b4ee
--- /dev/null
+++
b/integrations/observability/otel/src/test/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpansTest.java
@@ -0,0 +1,783 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.observability.otel;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import org.apache.flink.agents.api.trace.TraceRecord;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link AgentTraceSpans}: span topology, attributes, and id
determinism. */
+class AgentTraceSpansTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final String RUN = "7f1b5d20-86c6-4a5d-b65a-9c41757f2e11";
+ private static final String ACTION = "act-1111";
+ private static final String LLM = "llm-2222";
+ private static final String TOOL = "tool-3333";
+
+ // The metadata ToolCallAction records: the provider's call id and the
tool type.
+ private static final String TOOL_METADATA =
+
"\"entityMetadata\":{\"toolCallId\":\"call_abc\",\"toolType\":\"function\"},";
+
+ private static TraceRecord record(String json) {
+ try {
+ return MAPPER.readValue(json, TraceRecord.class);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static List<TraceRecord> sampleRun() {
+ return List.of(
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:00Z\",\"inputRunId\":\""
+ + RUN
+ +
"\",\"businessKey\":\"order-1001\",\"agentName\":\"ReActAgent\","
+ + "\"executionId\":\""
+ + ACTION
+ + "\",\"entityType\":\"action\","
+ + "\"entityName\":\"review\","
+ + "\"status\":\"started\"}"),
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:01Z\",\"inputRunId\":\""
+ + RUN
+ +
"\",\"businessKey\":\"order-1001\",\"executionId\":\""
+ + LLM
+ + "\",\"parentExecutionId\":\""
+ + ACTION
+ + "\","
+ +
"\"entityType\":\"llm\",\"entityName\":\"chatModel\","
+ +
"\"entityMetadata\":{\"model\":\"qwen-max\"},"
+ + "\"status\":\"started\"}"),
+ record(
+
"{\"schemaVersion\":1,\"timestamp\":\"2026-01-15T10:30:03Z\",\"inputRunId\":\""
+ + RUN
+ + "\",\"executionId\":\""
+ + LLM
+ + "\",\"parentExecutionId\":\""
+ + ACTION
+ +
"\",\"entityType\":\"llm\",\"entityName\":\"chatModel\","
+ + "\"entityMetadata\":{\"model\":\"qwen-max\","
+ +
"\"usage\":{\"promptTokens\":120,\"completionTokens\":45}},"
+ + "\"status\":\"success\",\"attributes\":{}}"),
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:04Z\",\"inputRunId\":\""
+ + RUN
+ + "\",\"executionId\":\""
+ + TOOL
+ + "\",\"parentExecutionId\":\""
+ + ACTION
+ +
"\",\"entityType\":\"tool\",\"entityName\":\"get_weather\","
+ + TOOL_METADATA
+ + "\"status\":\"started\"}"),
+ record(
+
"{\"schemaVersion\":1,\"timestamp\":\"2026-01-15T10:30:05Z\",\"inputRunId\":\""
+ + RUN
+ + "\",\"executionId\":\""
+ + TOOL
+ + "\",\"parentExecutionId\":\""
+ + ACTION
+ +
"\",\"entityType\":\"tool\",\"entityName\":\"get_weather\","
+ + TOOL_METADATA
+ + "\"status\":\"failed\","
+ + "\"problemCategory\":\"tool_call_failed\","
+ +
"\"attributes\":{\"errorType\":\"java.io.IOException\","
+ + "\"errorMessage\":\"connection refused\"}}"),
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:06Z\",\"inputRunId\":\""
+ + RUN
+ + "\",\"executionId\":\""
+ + ACTION
+ + "\",\"entityType\":\"action\","
+ + "\"entityName\":\"review\","
+ + "\"status\":\"success\"}"));
+ }
+
+ private static SpanData spanNamed(List<SpanData> spans, String name) {
+ return spans.stream()
+ .filter(s -> s.getName().equals(name))
+ .findFirst()
+ .orElseThrow(
+ () ->
+ new AssertionError(
+ "no span named '"
+ + name
+ + "' in "
+ + spans.stream()
+ .map(SpanData::getName)
+
.collect(Collectors.toList())));
+ }
+
+ @Test
+ @DisplayName("One input run becomes one trace with a synthesized
invoke_agent root span")
+ void testRunBecomesSingleTraceWithRootSpan() {
+ List<SpanData> spans = new
AgentTraceSpans("test-service").assemble(sampleRun());
+
+ assertThat(spans).hasSize(4);
+ assertThat(spans.stream().map(s ->
s.getSpanContext().getTraceId()).distinct()).hasSize(1);
+
+ SpanData root = spanNamed(spans, "invoke_agent ReActAgent");
+ assertThat(root.getParentSpanContext().isValid()).isFalse();
+
assertThat(root.getAttributes().get(AgentTraceSpans.GEN_AI_OPERATION_NAME))
+ .isEqualTo("invoke_agent");
+ assertThat(root.getAttributes().get(AgentTraceSpans.GEN_AI_AGENT_NAME))
+ .isEqualTo("ReActAgent");
+ assertThat(root.getAttributes().get(AgentTraceSpans.FA_BUSINESS_KEY))
+ .isEqualTo("order-1001");
+ // Root covers the whole run.
+ assertThat(root.getStartEpochNanos())
+ .isEqualTo(
+
spans.stream().mapToLong(SpanData::getStartEpochNanos).min().orElseThrow());
+ assertThat(root.getEndEpochNanos())
+ .isEqualTo(
+
spans.stream().mapToLong(SpanData::getEndEpochNanos).max().orElseThrow());
+ }
+
+ @Test
+ @DisplayName("Execution hierarchy maps to span parenting via
parentExecutionId")
+ void testParenting() {
+ List<SpanData> spans = new
AgentTraceSpans("test-service").assemble(sampleRun());
+
+ SpanData root = spanNamed(spans, "invoke_agent ReActAgent");
+ SpanData action = spanNamed(spans, "action review");
+ SpanData llm = spanNamed(spans, "chat qwen-max");
+ SpanData tool = spanNamed(spans, "execute_tool get_weather");
+
+ assertThat(action.getParentSpanContext().getSpanId())
+ .isEqualTo(root.getSpanContext().getSpanId());
+ assertThat(llm.getParentSpanContext().getSpanId())
+ .isEqualTo(action.getSpanContext().getSpanId());
+ assertThat(tool.getParentSpanContext().getSpanId())
+ .isEqualTo(action.getSpanContext().getSpanId());
+ }
+
+ @Test
+ @DisplayName("GenAI attributes: chat span carries model + usage, tool
failure carries error")
+ void testGenAiAttributes() {
+ List<SpanData> spans = new
AgentTraceSpans("test-service").assemble(sampleRun());
+
+ SpanData llm = spanNamed(spans, "chat qwen-max");
+ assertThat(llm.getKind()).isEqualTo(SpanKind.CLIENT);
+
assertThat(llm.getAttributes().get(AgentTraceSpans.GEN_AI_OPERATION_NAME))
+ .isEqualTo("chat");
+
assertThat(llm.getAttributes().get(AgentTraceSpans.GEN_AI_REQUEST_MODEL))
+ .isEqualTo("qwen-max");
+
assertThat(llm.getAttributes().get(AgentTraceSpans.GEN_AI_USAGE_INPUT_TOKENS))
+ .isEqualTo(120L);
+
assertThat(llm.getAttributes().get(AgentTraceSpans.GEN_AI_USAGE_OUTPUT_TOKENS))
+ .isEqualTo(45L);
+
+ SpanData tool = spanNamed(spans, "execute_tool get_weather");
+ // INTERNAL: the record cannot distinguish an in-process function tool
from a remote
+ // MCP tool, so the converter does not claim remoteness.
+ assertThat(tool.getKind()).isEqualTo(SpanKind.INTERNAL);
+
assertThat(tool.getStatus().getStatusCode()).isEqualTo(StatusCode.ERROR);
+ assertThat(tool.getStatus().getDescription()).isEqualTo("connection
refused");
+ assertThat(tool.getAttributes().get(AgentTraceSpans.ERROR_TYPE))
+ .isEqualTo("java.io.IOException");
+ assertThat(tool.getAttributes().get(AgentTraceSpans.GEN_AI_TOOL_NAME))
+ .isEqualTo("get_weather");
+ // The provider-issued id wins over the framework-assigned one.
+
assertThat(tool.getAttributes().get(AgentTraceSpans.GEN_AI_TOOL_CALL_ID))
+ .isEqualTo("call_abc");
+ assertThat(tool.getAttributes().get(AgentTraceSpans.GEN_AI_TOOL_TYPE))
+ .isEqualTo("function");
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_TOOL_TYPE)).isEqualTo("function");
+ }
+
+ @Test
+ @DisplayName("Chat span without a recorded model is named 'chat' and sets
no request model")
+ void testChatWithoutModel() {
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:01Z\","
+ +
"\"inputRunId\":\"r\",\"executionId\":\"l\","
+ +
"\"entityType\":\"llm\",\"entityName\":\"chatModel\","
+ +
"\"status\":\"success\"}")));
+
+ SpanData llm = spanNamed(spans, "chat");
+
assertThat(llm.getAttributes().get(AgentTraceSpans.GEN_AI_REQUEST_MODEL)).isNull();
+ }
+
+ @Test
+ @DisplayName("Tool type maps onto gen_ai.tool.type well-known values, raw
value always kept")
+ void testToolTypeMapping() {
+
assertThat(toolSpan("{\"toolCallId\":\"c\",\"toolType\":\"mcp\"}").getAttributes())
+ .satisfies(
+ a -> {
+ assertThat(a.get(AgentTraceSpans.GEN_AI_TOOL_TYPE))
+ .isEqualTo("extension");
+
assertThat(a.get(AgentTraceSpans.FA_TOOL_TYPE)).isEqualTo("mcp");
+ // Without a provider id, the framework call id is
used.
+
assertThat(a.get(AgentTraceSpans.GEN_AI_TOOL_CALL_ID)).isEqualTo("c");
+ });
+
assertThat(toolSpan("{\"toolType\":\"remote_function\"}").getAttributes())
+ .satisfies(
+ a ->
+
assertThat(a.get(AgentTraceSpans.GEN_AI_TOOL_TYPE))
+ .isEqualTo("extension"));
+
assertThat(toolSpan("{\"toolType\":\"model_built_in\"}").getAttributes())
+ .satisfies(
+ a -> {
+
assertThat(a.get(AgentTraceSpans.GEN_AI_TOOL_TYPE)).isNull();
+ assertThat(a.get(AgentTraceSpans.FA_TOOL_TYPE))
+ .isEqualTo("model_built_in");
+ });
+ }
+
+ @Test
+ @DisplayName("error.type falls back to problemCategory when no error type
is recorded")
+ void testErrorTypeFallsBackToProblemCategory() {
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:05Z\","
+ +
"\"inputRunId\":\"r\",\"executionId\":\"t\","
+ +
"\"entityType\":\"tool\",\"entityName\":\"lookup\","
+ +
"\"status\":\"failed\","
+ +
"\"problemCategory\":\"tool_call_failed\"}")));
+
+ SpanData tool = spanNamed(spans, "execute_tool lookup");
+
assertThat(tool.getStatus().getStatusCode()).isEqualTo(StatusCode.ERROR);
+ assertThat(tool.getAttributes().get(AgentTraceSpans.ERROR_TYPE))
+ .isEqualTo("tool_call_failed");
+ }
+
+ private static SpanData toolSpan(String entityMetadataJson) {
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:05Z\","
+ +
"\"inputRunId\":\"r\",\"executionId\":\"t\","
+ +
"\"entityType\":\"tool\",\"entityName\":\"lookup\","
+ + "\"entityMetadata\":"
+ + entityMetadataJson
+ + ","
+ +
"\"status\":\"success\"}")));
+ return spanNamed(spans, "execute_tool lookup");
+ }
+
+ @Test
+ @DisplayName("Ids are derived deterministically: re-assembly yields
identical trace/span ids")
+ void testDeterministicIds() {
+ AgentTraceSpans assembler = new AgentTraceSpans("test-service");
+ List<SpanData> first = assembler.assemble(sampleRun());
+ List<SpanData> second = assembler.assemble(sampleRun());
+
+ assertThat(
+ first.stream()
+ .map(s -> s.getSpanContext().getSpanId())
+ .collect(Collectors.toList()))
+ .isEqualTo(
+ second.stream()
+ .map(s -> s.getSpanContext().getSpanId())
+ .collect(Collectors.toList()));
+ assertThat(first.get(0).getSpanContext().getTraceId())
+ .isEqualTo(second.get(0).getSpanContext().getTraceId());
+ }
+
+ @Test
+ @DisplayName("Record order does not affect the assembled spans")
+ void testRecordOrderDoesNotMatter() {
+ AgentTraceSpans assembler = new AgentTraceSpans("test-service");
+ List<TraceRecord> reversed = new ArrayList<>(sampleRun());
+ Collections.reverse(reversed);
+
+ assertThat(spanShapes(assembler.assemble(reversed)))
+
.containsExactlyInAnyOrderElementsOf(spanShapes(assembler.assemble(sampleRun())));
+ }
+
+ private static List<String> spanShapes(List<SpanData> spans) {
+ return spans.stream()
+ .map(
+ s ->
+ s.getName()
+ + "|"
+ + s.getSpanContext().getSpanId()
+ + "|"
+ + s.getParentSpanContext().getSpanId()
+ + "|"
+ + s.getStartEpochNanos()
+ + "|"
+ + s.getEndEpochNanos()
+ + "|"
+ + s.getStatus().getStatusCode()
+ + "|"
+ + s.getAttributes())
+ .collect(Collectors.toList());
+ }
+
+ @Test
+ @DisplayName("Every execution span carries the framework correlation
attributes")
+ void testCorrelationAttributes() {
+ SpanData tool =
+ spanNamed(
+ new
AgentTraceSpans("test-service").assemble(sampleRun()),
+ "execute_tool get_weather");
+
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_INPUT_RUN_ID)).isEqualTo(RUN);
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_EXECUTION_ID)).isEqualTo(TOOL);
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_ENTITY_TYPE)).isEqualTo("tool");
+ assertThat(tool.getAttributes().get(AgentTraceSpans.FA_ENTITY_NAME))
+ .isEqualTo("get_weather");
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_EXECUTION_STATUS))
+ .isEqualTo("failed");
+ }
+
+ @Test
+ @DisplayName("businessKey is always flink_agents.business_key;
conversation id is opt-in")
+ void testBusinessKeyAsConversationIdIsOptIn() {
+ SpanData byDefault =
+ spanNamed(
+ new
AgentTraceSpans("test-service").assemble(sampleRun()),
+ "invoke_agent ReActAgent");
+
assertThat(byDefault.getAttributes().get(AgentTraceSpans.FA_BUSINESS_KEY))
+ .isEqualTo("order-1001");
+
assertThat(byDefault.getAttributes().get(AgentTraceSpans.GEN_AI_CONVERSATION_ID)).isNull();
+
+ List<SpanData> optedIn = new AgentTraceSpans("test-service",
true).assemble(sampleRun());
+ for (String name : List.of("invoke_agent ReActAgent", "chat
qwen-max")) {
+ SpanData span = spanNamed(optedIn, name);
+
assertThat(span.getAttributes().get(AgentTraceSpans.FA_BUSINESS_KEY))
+ .isEqualTo("order-1001");
+
assertThat(span.getAttributes().get(AgentTraceSpans.GEN_AI_CONVERSATION_ID))
+ .isEqualTo("order-1001");
+ }
+ }
+
+ @Test
+ @DisplayName("Tool spans carry gen_ai.agent.name when their records have
one")
+ void testToolSpanCarriesAgentName() {
+ SpanData named =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ toolRecord(
+ "2026-01-15T10:30:04Z",
+ "started",
+
",\"agentName\":\"ReActAgent\""),
+ toolRecord("2026-01-15T10:30:05Z",
"success")))
+ .get(1);
+
assertThat(named.getAttributes().get(AgentTraceSpans.GEN_AI_AGENT_NAME))
+ .isEqualTo("ReActAgent");
+
+ SpanData unnamed =
+ new AgentTraceSpans("test-service")
+ .assemble(List.of(toolRecord("2026-01-15T10:30:04Z",
"started")))
+ .get(1);
+
assertThat(unnamed.getAttributes().get(AgentTraceSpans.GEN_AI_AGENT_NAME)).isNull();
+ }
+
+ @Test
+ @DisplayName("A record with an invalid timestamp is skipped with a
diagnostic")
+ void testInvalidTimestampIsSkippedWithDiagnostic() {
+ List<ConverterDiagnostic> diagnostics = new ArrayList<>();
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ toolRecord("yesterday", "started"),
+ toolRecord("2026-01-15T10:30:05Z",
"success")),
+ diagnostics);
+
+ // The valid terminal record still becomes a span; the invalid start
is reported.
+ assertThat(spans).hasSize(2);
+ assertThat(diagnostics)
+ .anySatisfy(
+ d -> {
+
assertThat(d.getCode()).isEqualTo(ConverterDiagnostic.MALFORMED_RECORD);
+ assertThat(d.getMessage()).contains("yesterday");
+ });
+ }
+
+ private static TraceRecord toolRecord(String timestamp, String status) {
+ return toolRecord(timestamp, status, "");
+ }
+
+ /**
+ * {@code extraJson} adds fields after the status; empty attributes are
added if it has none.
+ */
+ private static TraceRecord toolRecord(String timestamp, String status,
String extraJson) {
+ return record(
+ "{\"schemaVersion\":1,\"timestamp\":\""
+ + timestamp
+ + "\",\"inputRunId\":\"r\",\"executionId\":\"t\","
+ + "\"entityType\":\"tool\",\"entityName\":\"lookup\","
+ + "\"status\":\""
+ + status
+ + "\""
+ + extraJson
+ + (extraJson.contains("\"attributes\"") ? "" :
",\"attributes\":{}")
+ + "}");
+ }
+
+ @Test
+ @DisplayName("created -> started -> failed spans started..failed in any
record order")
+ void testCreatedStartedTerminal() {
+ List<TraceRecord> records =
+ new ArrayList<>(
+ List.of(
+ toolRecord("2026-01-15T10:30:00Z", "created"),
+ toolRecord("2026-01-15T10:30:01Z", "started"),
+ toolRecord(
+ "2026-01-15T10:30:03Z",
+ "failed",
+
",\"problemCategory\":\"tool_call_failed\"")));
+ for (int pass = 0; pass < 2; pass++) {
+ List<ConverterDiagnostic> diagnostics = new ArrayList<>();
+ SpanData tool =
+ spanNamed(
+ new
AgentTraceSpans("test-service").assemble(records, diagnostics),
+ "execute_tool lookup");
+
+
assertThat(tool.getStatus().getStatusCode()).isEqualTo(StatusCode.ERROR);
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_EXECUTION_STATUS))
+ .isEqualTo("failed");
+ assertThat(tool.getEndEpochNanos() - tool.getStartEpochNanos())
+ .isEqualTo(2_000_000_000L);
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_EXECUTION_INCOMPLETE)).isNull();
+ assertThat(diagnostics).isEmpty();
+ Collections.reverse(records);
+ }
+ }
+
+ @Test
+ @DisplayName("A created-only execution is incomplete, not a terminal
without a start")
+ void testCreatedOnly() {
+ List<ConverterDiagnostic> diagnostics = new ArrayList<>();
+ SpanData tool =
+ spanNamed(
+ new AgentTraceSpans("test-service")
+ .assemble(
+
List.of(toolRecord("2026-01-15T10:30:00Z", "created")),
+ diagnostics),
+ "execute_tool lookup");
+
+
assertThat(tool.getStatus().getStatusCode()).isEqualTo(StatusCode.UNSET);
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_EXECUTION_INCOMPLETE)).isTrue();
+
assertThat(tool.getStartEpochNanos()).isEqualTo(tool.getEndEpochNanos());
+ assertThat(diagnostics)
+ .extracting(ConverterDiagnostic::getCode)
+ .containsExactly(ConverterDiagnostic.INCOMPLETE_EXECUTION);
+ }
+
+ @Test
+ @DisplayName("created -> failed without a start (preparation failure)
spans created..failed")
+ void testCreatedThenFailedWithoutStart() {
+ List<ConverterDiagnostic> diagnostics = new ArrayList<>();
+ SpanData tool =
+ spanNamed(
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+
toolRecord("2026-01-15T10:30:00Z", "created"),
+
toolRecord("2026-01-15T10:30:00.500Z", "failed")),
+ diagnostics),
+ "execute_tool lookup");
+
+
assertThat(tool.getStatus().getStatusCode()).isEqualTo(StatusCode.ERROR);
+ assertThat(tool.getEndEpochNanos() -
tool.getStartEpochNanos()).isEqualTo(500_000_000L);
+ assertThat(diagnostics).isEmpty();
+ }
+
+ @Test
+ @DisplayName("A STANDARD-level truncated error message still becomes the
status description")
+ void testTruncatedErrorMessage() {
+ SpanData tool =
+ spanNamed(
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ toolRecord(
+ "2026-01-15T10:30:00Z",
+ "failed",
+
",\"attributes\":{\"errorType\":\"x.Boom\","
+ +
"\"errorMessage\":{\"truncatedString\":"
+ + "\"long
message...\",\"omittedChars\":42}}"))),
+ "execute_tool lookup");
+
+ assertThat(tool.getStatus().getDescription()).isEqualTo("long
message...");
+
assertThat(tool.getAttributes().get(AgentTraceSpans.ERROR_TYPE)).isEqualTo("x.Boom");
+ }
+
+ @Test
+ @DisplayName("A reused execution becomes a point-in-time span with status
attribute 'reused'")
+ void testReusedExecution() {
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:00Z\","
+ + "\"inputRunId\":\""
+ + RUN
+ + "\","
+ + "\"executionId\":\""
+ + ACTION
+ + "\","
+ +
"\"entityType\":\"action\",\"entityName\":\"review\","
+ +
"\"status\":\"reused\"}")));
+
+ SpanData reused = spanNamed(spans, "action review");
+
assertThat(reused.getStartEpochNanos()).isEqualTo(reused.getEndEpochNanos());
+
assertThat(reused.getAttributes().get(AgentTraceSpans.FA_EXECUTION_STATUS))
+ .isEqualTo("reused");
+ // A reused execution is single-record by design: complete, no
diagnostic.
+
assertThat(reused.getAttributes().get(AgentTraceSpans.FA_EXECUTION_INCOMPLETE)).isNull();
+ }
+
+ @Test
+ @DisplayName("A started execution without a terminal record is exported as
incomplete")
+ void testIncompleteExecution() {
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:00Z\","
+ + "\"inputRunId\":\""
+ + RUN
+ + "\","
+ + "\"executionId\":\""
+ + LLM
+ + "\","
+ +
"\"entityType\":\"llm\",\"entityName\":\"chatModel\","
+ +
"\"entityMetadata\":{\"model\":\"qwen-max\"},"
+ +
"\"status\":\"started\"}")));
+
+ SpanData span = spanNamed(spans, "chat qwen-max");
+
assertThat(span.getAttributes().get(AgentTraceSpans.FA_EXECUTION_INCOMPLETE)).isTrue();
+
assertThat(span.getStartEpochNanos()).isEqualTo(span.getEndEpochNanos());
+ // UNSET, not ERROR: a missing terminal is indistinguishable between a
crash, a dropped
+ // best-effort write, and recovery discarding the pairing.
+
assertThat(span.getStatus().getStatusCode()).isEqualTo(StatusCode.UNSET);
+ }
+
+ @Test
+ @DisplayName("Incomplete and start-less executions produce
machine-readable diagnostics")
+ void testDiagnosticsForIncompleteAndMissingStart() {
+ List<ConverterDiagnostic> diagnostics = new ArrayList<>();
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:00Z\","
+ + "\"inputRunId\":\""
+ + RUN
+ + "\","
+ + "\"executionId\":\""
+ + LLM
+ + "\","
+ +
"\"entityType\":\"llm\",\"entityName\":\"chatModel\","
+ +
"\"entityMetadata\":{\"model\":\"qwen-max\"},"
+ +
"\"status\":\"started\"}"),
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:02Z\","
+ + "\"inputRunId\":\""
+ + RUN
+ + "\","
+ + "\"executionId\":\""
+ + TOOL
+ + "\","
+ +
"\"entityType\":\"tool\",\"entityName\":\"get_weather\","
+ +
"\"status\":\"success\"}")),
+ diagnostics);
+
+ assertThat(spans).hasSize(3); // run root + two executions
+ assertThat(diagnostics).hasSize(2);
+ assertThat(diagnostics)
+ .anySatisfy(
+ d -> {
+ assertThat(d.getCode())
+
.isEqualTo(ConverterDiagnostic.INCOMPLETE_EXECUTION);
+ assertThat(d.getExecutionId()).isEqualTo(LLM);
+ });
+ assertThat(diagnostics)
+ .anySatisfy(
+ d -> {
+
assertThat(d.getCode()).isEqualTo(ConverterDiagnostic.MISSING_START);
+ assertThat(d.getExecutionId()).isEqualTo(TOOL);
+ });
+ SpanData tool = spanNamed(spans, "execute_tool get_weather");
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_EXECUTION_INCOMPLETE)).isTrue();
+ }
+
+ @Test
+ @DisplayName("Parser executions map to 'parse {name}', custom operation
name, INTERNAL kind")
+ void testParserMapping() {
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:00Z\","
+ + "\"inputRunId\":\""
+ + RUN
+ + "\","
+ +
"\"executionId\":\"p-1\","
+ +
"\"entityType\":\"parser\","
+ +
"\"entityName\":\"json_output\","
+ +
"\"status\":\"started\"}"),
+ record(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:01Z\","
+ + "\"inputRunId\":\""
+ + RUN
+ + "\","
+ +
"\"executionId\":\"p-1\","
+ +
"\"entityType\":\"parser\","
+ +
"\"entityName\":\"json_output\","
+ +
"\"status\":\"success\"}")));
+
+ SpanData parser = spanNamed(spans, "parse json_output");
+ assertThat(parser.getKind()).isEqualTo(SpanKind.INTERNAL);
+
assertThat(parser.getAttributes().get(AgentTraceSpans.GEN_AI_OPERATION_NAME))
+ .isEqualTo("parse");
+ }
+
+ @Test
+ @DisplayName("Event records and execution records without a run are
ignored")
+ void testNonLifecycleRecordsIgnored() {
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ record(
+ "{\"schemaVersion\":1,"
+ +
"\"timestamp\":\"2026-01-15T10:30:00Z\","
+ +
"\"inputRunId\":\"r\",\"entityType\":\"event\","
+ +
"\"entityName\":\"_chat_request_event\","
+ +
"\"entityMetadata\":{\"eventId\":\"e-1\"},"
+ +
"\"attributes\":{\"model\":\"gpt-4\"}}"),
+ record(
+ "{\"schemaVersion\":1,"
+ +
"\"timestamp\":\"2026-01-15T10:30:00Z\","
+ +
"\"executionId\":\"x\",\"entityType\":\"tool\","
+ +
"\"entityName\":\"lookup\","
+ +
"\"status\":\"started\",\"attributes\":{}}")));
+
+ assertThat(spans).isEmpty();
+ }
+
+ @Test
+ @DisplayName(
+ "A child whose parent has no records is attached to the run root,
with a diagnostic")
+ void testMissingParentAttachesToRunRoot() {
+ List<ConverterDiagnostic> diagnostics = new ArrayList<>();
+ List<SpanData> spans =
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ toolRecord(
+ "2026-01-15T10:30:04Z",
+ "started",
+
",\"parentExecutionId\":\"act-missing\""),
+ toolRecord(
+ "2026-01-15T10:30:05Z",
+ "success",
+
",\"parentExecutionId\":\"act-missing\"")),
+ diagnostics);
+
+ SpanData root = spanNamed(spans, "invoke_agent");
+ SpanData tool = spanNamed(spans, "execute_tool lookup");
+ assertThat(tool.getParentSpanId()).isEqualTo(root.getSpanId());
+
assertThat(tool.getAttributes().get(AgentTraceSpans.FA_PARENT_EXECUTION_ID))
+ .isEqualTo("act-missing");
+ assertThat(diagnostics)
+ .singleElement()
+ .satisfies(
+ d -> {
+
assertThat(d.getCode()).isEqualTo(ConverterDiagnostic.MISSING_PARENT);
+ assertThat(d.getExecutionId()).isEqualTo("t");
+ });
+ }
+
+ @Test
+ @DisplayName("Repeated records keep the earliest of each kind, whatever
their order")
+ void testDuplicateRecordsKeepEarliest() {
+ List<TraceRecord> records =
+ new ArrayList<>(
+ List.of(
+ toolRecord("2026-01-15T10:30:01Z", "started"),
+ toolRecord("2026-01-15T10:30:02Z", "started"),
+ toolRecord("2026-01-15T10:30:04Z", "success"),
+ toolRecord("2026-01-15T10:30:06Z",
"success")));
+ for (int pass = 0; pass < 2; pass++) {
+ SpanData tool =
+ spanNamed(
+ new
AgentTraceSpans("test-service").assemble(records),
+ "execute_tool lookup");
+
+ assertThat(tool.getEndEpochNanos() - tool.getStartEpochNanos())
+ .isEqualTo(3_000_000_000L);
+ Collections.reverse(records);
+ }
+ }
+
+ @Test
+ @DisplayName("A sub-agent delegation maps to invoke_agent {name} with the
tool call id")
+ void testSubagentMapping() {
+ String delegation =
+
"\"inputRunId\":\"r\",\"executionId\":\"s\",\"entityType\":\"subagent\","
+ + "\"entityName\":\"researcher\","
+ +
"\"entityMetadata\":{\"toolCallId\":\"call_1\"},\"attributes\":{},";
+ SpanData span =
+ spanNamed(
+ new AgentTraceSpans("test-service")
+ .assemble(
+ List.of(
+ record(
+ "{\"schemaVersion\":1,"
+ + delegation
+ +
"\"timestamp\":\"2026-01-15T10:30:00Z\","
+ +
"\"status\":\"started\"}"),
+ record(
+ "{\"schemaVersion\":1,"
+ + delegation
+ +
"\"timestamp\":\"2026-01-15T10:30:02Z\","
+ +
"\"status\":\"success\"}"))),
+ "invoke_agent researcher");
+
+ assertThat(span.getKind()).isEqualTo(SpanKind.INTERNAL);
+
assertThat(span.getAttributes().get(AgentTraceSpans.GEN_AI_OPERATION_NAME))
+ .isEqualTo("invoke_agent");
+ assertThat(span.getAttributes().get(AgentTraceSpans.GEN_AI_AGENT_NAME))
+ .isEqualTo("researcher");
+
assertThat(span.getAttributes().get(AgentTraceSpans.GEN_AI_TOOL_CALL_ID))
+ .isEqualTo("call_1");
+ }
+}
diff --git
a/integrations/observability/otel/src/test/java/org/apache/flink/agents/integrations/observability/otel/TraceLogOTelExporterTest.java
b/integrations/observability/otel/src/test/java/org/apache/flink/agents/integrations/observability/otel/TraceLogOTelExporterTest.java
new file mode 100644
index 000000000..78347cf3d
--- /dev/null
+++
b/integrations/observability/otel/src/test/java/org/apache/flink/agents/integrations/observability/otel/TraceLogOTelExporterTest.java
@@ -0,0 +1,413 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.observability.otel;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.opentelemetry.sdk.common.CompletableResultCode;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.export.SpanExporter;
+import org.apache.flink.agents.api.trace.TraceContext;
+import org.apache.flink.agents.api.trace.TraceRecord;
+import org.apache.flink.agents.api.trace.TraceRecords;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** End-to-end test: Trace Log JSONL file in, spans out through an in-memory
exporter. */
+class TraceLogOTelExporterTest {
+
+ /** Collects exported spans in memory; no network involved. */
+ private static final class CollectingSpanExporter implements SpanExporter {
+ private final List<SpanData> spans = new ArrayList<>();
+ private final List<Integer> batchSizes = new ArrayList<>();
+ private int failingCall = -1;
+ private boolean shutDown;
+
+ @Override
+ public CompletableResultCode export(Collection<SpanData> collection) {
+ if (batchSizes.size() == failingCall) {
+ batchSizes.add(collection.size());
+ return CompletableResultCode.ofFailure();
+ }
+ batchSizes.add(collection.size());
+ spans.addAll(collection);
+ return CompletableResultCode.ofSuccess();
+ }
+
+ @Override
+ public CompletableResultCode flush() {
+ return CompletableResultCode.ofSuccess();
+ }
+
+ @Override
+ public CompletableResultCode shutdown() {
+ shutDown = true;
+ return CompletableResultCode.ofSuccess();
+ }
+ }
+
+ @Test
+ @DisplayName("Exports a JSONL Trace Log file, reading Event records
without making spans")
+ void testExportJsonlFile(@TempDir Path tempDir) throws Exception {
+ String run = "7f1b5d20-86c6-4a5d-b65a-9c41757f2e11";
+ Path log = tempDir.resolve("traces-job-task-0.log");
+ Files.write(
+ log,
+ List.of(
+ // An Event record: read but produces no span.
+
"{\"schemaVersion\":1,\"timestamp\":\"2026-01-15T10:29:59Z\","
+ + "\"inputRunId\":\""
+ + run
+ +
"\",\"entityType\":\"event\",\"entityName\":\"_input_event\","
+ +
"\"entityMetadata\":{\"eventId\":\"e-1\"},\"attributes\":{}}",
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:00Z\",\"inputRunId\":\""
+ + run
+ + "\","
+ +
"\"agentName\":\"ReActAgent\",\"executionId\":\"act-1\","
+ +
"\"entityType\":\"action\",\"entityName\":\"review\","
+ + "\"status\":\"started\"}",
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:02Z\",\"inputRunId\":\""
+ + run
+ + "\","
+ +
"\"executionId\":\"act-1\",\"entityType\":\"action\","
+ + "\"entityName\":\"review\","
+ + "\"status\":\"success\"}"));
+
+ CollectingSpanExporter collector = new CollectingSpanExporter();
+ TraceLogOTelExporter exporter =
+ TraceLogOTelExporter.builder()
+ .setServiceName("test-job")
+ .setSpanExporter(collector)
+ .build();
+
+ TraceLogOTelExporter.ExportSummary summary =
exporter.exportFiles(List.of(log));
+
+ assertThat(summary.getRecordsRead()).isEqualTo(3);
+ // Root run span + the action span.
+ assertThat(summary.getSpansExported()).isEqualTo(2);
+ assertThat(collector.spans).hasSize(2);
+ assertThat(collector.spans)
+ .allSatisfy(
+ span ->
+ assertThat(
+ span.getResource()
+ .getAttributes()
+ .get(
+
io.opentelemetry.api.common
+
.AttributeKey.stringKey(
+
"service.name")))
+ .isEqualTo("test-job"));
+
+ exporter.shutdown();
+ assertThat(collector.shutDown).isTrue();
+ }
+
+ @Test
+ @DisplayName("A malformed record yields a MALFORMED_RECORD diagnostic
naming the file")
+ void testMalformedRecordDiagnostic(@TempDir Path tempDir) throws Exception
{
+ Path log = tempDir.resolve("traces-job-task-0.log");
+ Files.write(
+ log,
+ List.of(
+ "{\"timestamp\": not-valid",
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:00Z\",\"inputRunId\":\"run-1\","
+ +
"\"executionId\":\"act-1\",\"entityType\":\"action\","
+ + "\"entityName\":\"review\","
+ + "\"status\":\"started\"}"));
+
+ CollectingSpanExporter collector = new CollectingSpanExporter();
+ TraceLogOTelExporter exporter =
+ TraceLogOTelExporter.builder()
+ .setServiceName("test-job")
+ .setSpanExporter(collector)
+ .build();
+
+ TraceLogOTelExporter.ExportSummary summary =
exporter.exportFiles(List.of(log));
+
+ assertThat(summary.getDiagnostics())
+ .anySatisfy(
+ d -> {
+
assertThat(d.getCode()).isEqualTo(ConverterDiagnostic.MALFORMED_RECORD);
+
assertThat(d.getFilePath()).isEqualTo(log.toString());
+ });
+ exporter.shutdown();
+ }
+
+ @Test
+ @DisplayName("A file that is not JSON from its first byte does not stop
the other files")
+ void testUnparseableFileDoesNotStopOtherFiles(@TempDir Path tempDir)
throws Exception {
+ Path broken = tempDir.resolve("traces-job-task-0.log");
+ Files.write(broken, List.of("not-valid"));
+ Path valid = tempDir.resolve("traces-job-task-1.log");
+ String action =
+
"{\"schemaVersion\":1,\"attributes\":{},\"inputRunId\":\"run-1\","
+ +
"\"executionId\":\"act-1\",\"entityType\":\"action\","
+ + "\"entityName\":\"review\",";
+ Files.write(
+ valid,
+ List.of(
+ action +
"\"timestamp\":\"2026-01-15T10:30:00Z\",\"status\":\"started\"}",
+ action +
"\"timestamp\":\"2026-01-15T10:30:01Z\",\"status\":\"success\"}"));
+
+ CollectingSpanExporter collector = new CollectingSpanExporter();
+ TraceLogOTelExporter exporter =
+
TraceLogOTelExporter.builder().setSpanExporter(collector).build();
+ TraceLogOTelExporter.ExportSummary summary =
exporter.exportFiles(List.of(broken, valid));
+
+ assertThat(summary.getRecordsRead()).isEqualTo(2);
+ assertThat(collector.spans).hasSize(2);
+ assertThat(summary.getDiagnostics())
+ .singleElement()
+ .satisfies(
+ d -> {
+
assertThat(d.getCode()).isEqualTo(ConverterDiagnostic.MALFORMED_RECORD);
+
assertThat(d.getFilePath()).isEqualTo(broken.toString());
+ });
+ exporter.shutdown();
+ }
+
+ @Test
+ @DisplayName("A directory argument is expanded to its traces-*.log and
*.trace-log.log files")
+ void testDirectoryDiscovery(@TempDir Path tempDir) throws Exception {
+ Files.write(
+ tempDir.resolve("traces-job-task-0.log"),
+ List.of(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:00Z\",\"inputRunId\":\"run-1\","
+ +
"\"executionId\":\"act-1\",\"entityType\":\"action\","
+ + "\"entityName\":\"review\","
+ + "\"status\":\"started\"}"));
+ Files.write(
+ tempDir.resolve("job-task-1.trace-log.log"),
+ List.of(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:01Z\",\"inputRunId\":\"run-2\","
+ +
"\"executionId\":\"act-2\",\"entityType\":\"action\","
+ + "\"entityName\":\"review\","
+ + "\"status\":\"started\"}"));
+ // Not matching the File trace logger naming contract: ignored during
discovery.
+ Files.write(tempDir.resolve("other.txt"), List.of("not a log"));
+
+ CollectingSpanExporter collector = new CollectingSpanExporter();
+ TraceLogOTelExporter exporter =
+ TraceLogOTelExporter.builder()
+ .setServiceName("test-job")
+ .setSpanExporter(collector)
+ .build();
+
+ TraceLogOTelExporter.ExportSummary summary =
exporter.exportFiles(List.of(tempDir));
+
+ assertThat(summary.getRecordsRead()).isEqualTo(2);
+ // Two runs, each with a root span and one (incomplete) action span.
+ assertThat(summary.getSpansExported()).isEqualTo(4);
+ exporter.shutdown();
+ }
+
+ /** Two runs, each with a finished action: four spans. */
+ private static Path twoRunLog(Path tempDir) throws Exception {
+ Path log = tempDir.resolve("traces-job-task-0.log");
+ List<String> lines = new ArrayList<>();
+ for (String run : List.of("run-1", "run-2")) {
+ for (String event : List.of("started", "finished")) {
+ lines.add(
+
"{\"schemaVersion\":1,\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:0"
+ + (event.equals("started") ? "0" : "1")
+ + "Z\",\"inputRunId\":\""
+ + run
+ + "\",\"executionId\":\"act-"
+ + run
+ +
"\",\"entityType\":\"action\",\"entityName\":\"review\","
+ + "\"status\":\""
+ + (event.equals("started") ? "started" :
"success")
+ + "\"}");
+ }
+ }
+ Files.write(log, lines);
+ return log;
+ }
+
+ @Test
+ @DisplayName("Spans are exported in batches of at most the configured
size")
+ void testExportsInBoundedBatches(@TempDir Path tempDir) throws Exception {
+ CollectingSpanExporter collector = new CollectingSpanExporter();
+ TraceLogOTelExporter exporter =
+ TraceLogOTelExporter.builder()
+ .setSpanExporter(collector)
+ .setMaxExportBatchSize(3)
+ .build();
+
+ TraceLogOTelExporter.ExportSummary summary =
+ exporter.exportFiles(List.of(twoRunLog(tempDir)));
+
+ assertThat(summary.getSpansExported()).isEqualTo(4);
+ assertThat(collector.batchSizes).containsExactly(3, 1);
+ exporter.shutdown();
+ }
+
+ @Test
+ @DisplayName("A failed batch reports how many spans the earlier batches
delivered")
+ void testFailedBatchReportsPartialProgress(@TempDir Path tempDir) throws
Exception {
+ CollectingSpanExporter collector = new CollectingSpanExporter();
+ collector.failingCall = 1;
+ TraceLogOTelExporter exporter =
+ TraceLogOTelExporter.builder()
+ .setSpanExporter(collector)
+ .setMaxExportBatchSize(3)
+ .build();
+
+ assertThatThrownBy(() ->
exporter.exportFiles(List.of(twoRunLog(tempDir))))
+ .isInstanceOfSatisfying(
+ TraceLogOTelExporter.ExportFailedException.class,
+ e -> {
+ assertThat(e.getSpansExported()).isEqualTo(3);
+ assertThat(e.getSpansTotal()).isEqualTo(4);
+ });
+ assertThat(collector.spans).hasSize(3);
+ exporter.shutdown();
+ }
+
+ @Test
+ @DisplayName("Rejects a non-positive export batch size")
+ void testRejectsNonPositiveBatchSize() {
+ assertThatThrownBy(() ->
TraceLogOTelExporter.builder().setMaxExportBatchSize(0))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("Rejects unsupported OTLP protocols at build time")
+ void testUnsupportedProtocol() {
+ try {
+
TraceLogOTelExporter.builder().setProtocol("carrier-pigeon").build();
+ throw new AssertionError("expected IllegalArgumentException");
+ } catch (IllegalArgumentException expected) {
+ assertThat(expected).hasMessageContaining("carrier-pigeon");
+ }
+ }
+
+ @Test
+ @DisplayName("Records written by the Trace Log serializer export as one
parented trace")
+ void testReadsSerializedTraceRecords(@TempDir Path tempDir) throws
Exception {
+ TraceContext action =
+ TraceContext.forAction(
+ TraceContext.forInputRun("order-1001", "ReActAgent"),
"review", "input");
+ TraceContext llm =
+ action.childExecution("llm-1", "llm", "chatModel",
Map.of("model", "qwen-max"));
+ TraceContext llmDone =
+ action.childExecution(
+ "llm-1",
+ "llm",
+ "chatModel",
+ Map.of(
+ "model",
+ "qwen-max",
+ "usage",
+ Map.of("promptTokens", 120,
"completionTokens", 45)));
+ ObjectMapper mapper = new ObjectMapper();
+ List<String> lines = new ArrayList<>();
+ for (TraceRecord record :
+ List.of(
+ TraceRecords.started(action),
+ TraceRecords.started(llm),
+ TraceRecords.succeeded(llmDone),
+ TraceRecords.succeeded(action))) {
+ lines.add(mapper.writeValueAsString(record));
+ }
+ Path log = tempDir.resolve("traces-job-task-0.log");
+ Files.write(log, lines);
+
+ CollectingSpanExporter collector = new CollectingSpanExporter();
+ TraceLogOTelExporter exporter =
+
TraceLogOTelExporter.builder().setSpanExporter(collector).build();
+ TraceLogOTelExporter.ExportSummary summary =
exporter.exportFiles(List.of(log));
+
+ assertThat(summary.getDiagnostics()).isEmpty();
+ assertThat(collector.spans).hasSize(3);
+ SpanData actionSpan = spanNamed(collector.spans, "action review");
+ SpanData chatSpan = spanNamed(collector.spans, "chat qwen-max");
+
assertThat(chatSpan.getParentSpanId()).isEqualTo(actionSpan.getSpanId());
+
assertThat(chatSpan.getAttributes().get(AgentTraceSpans.GEN_AI_USAGE_INPUT_TOKENS))
+ .isEqualTo(120L);
+
assertThat(chatSpan.getAttributes().get(AgentTraceSpans.GEN_AI_USAGE_OUTPUT_TOKENS))
+ .isEqualTo(45L);
+ exporter.shutdown();
+ }
+
+ @Test
+ @DisplayName("A record without schemaVersion is malformed; reading
continues with the next")
+ void testMissingSchemaVersionIsMalformed(@TempDir Path tempDir) throws
Exception {
+ Path log = tempDir.resolve("traces-job-task-0.log");
+ String started =
+ "\"attributes\":{},\"timestamp\":\"2026-01-15T10:30:00Z\","
+ + "\"inputRunId\":\"run-1\",\"executionId\":\"act-1\","
+ +
"\"entityType\":\"action\",\"entityName\":\"review\","
+ + "\"status\":\"started\"}";
+ Files.write(log, List.of("{" + started, "{\"schemaVersion\":1," +
started));
+
+ TraceLogOTelExporter exporter =
+ TraceLogOTelExporter.builder()
+ .setSpanExporter(new CollectingSpanExporter())
+ .build();
+ TraceLogOTelExporter.ExportSummary summary =
exporter.exportFiles(List.of(log));
+
+ assertThat(summary.getRecordsRead()).isEqualTo(1);
+ assertThat(summary.getDiagnostics())
+ .anySatisfy(
+ d ->
+ assertThat(d.getCode())
+
.isEqualTo(ConverterDiagnostic.MALFORMED_RECORD));
+ exporter.shutdown();
+ }
+
+ @Test
+ @DisplayName("Fields the File and SLF4J trace loggers add around a record
are ignored")
+ void testLoggerFieldsAreIgnored(@TempDir Path tempDir) throws Exception {
+ Path log = tempDir.resolve("traces-job-task-0.log");
+ Files.write(
+ log,
+ List.of(
+
"{\"schemaVersion\":1,\"level\":\"STANDARD\",\"jobId\":\"j\","
+ +
"\"taskName\":\"t\",\"subtaskId\":0,\"attributes\":{},"
+ +
"\"timestamp\":\"2026-01-15T10:30:00Z\",\"inputRunId\":\"run-1\","
+ +
"\"executionId\":\"act-1\",\"entityType\":\"action\","
+ +
"\"entityName\":\"review\",\"status\":\"started\"}"));
+
+ CollectingSpanExporter collector = new CollectingSpanExporter();
+ TraceLogOTelExporter exporter =
+
TraceLogOTelExporter.builder().setSpanExporter(collector).build();
+ TraceLogOTelExporter.ExportSummary summary =
exporter.exportFiles(List.of(log));
+
+ assertThat(summary.getRecordsRead()).isEqualTo(1);
+ assertThat(collector.spans).hasSize(2);
+ exporter.shutdown();
+ }
+
+ private static SpanData spanNamed(List<SpanData> spans, String name) {
+ return spans.stream()
+ .filter(s -> s.getName().equals(name))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no span named '" + name
+ "'"));
+ }
+}
diff --git a/integrations/pom.xml b/integrations/observability/pom.xml
similarity index 64%
copy from integrations/pom.xml
copy to integrations/observability/pom.xml
index e691f2508..c80fc134e 100644
--- a/integrations/pom.xml
+++ b/integrations/observability/pom.xml
@@ -7,7 +7,9 @@ regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
+
http://www.apache.org/licenses/LICENSE-2.0
+
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@@ -22,29 +24,16 @@ under the License.
<parent>
<groupId>org.apache.flink</groupId>
- <artifactId>flink-agents</artifactId>
+ <artifactId>flink-agents-integrations</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
- <artifactId>flink-agents-integrations</artifactId>
- <name>Flink Agents : Integrations:</name>
+ <artifactId>flink-agents-integrations-observability</artifactId>
+ <name>Flink Agents : Integrations: Observability:</name>
<packaging>pom</packaging>
- <properties>
- <ollama4j.version>1.1.5</ollama4j.version>
- <elasticsearch.version>8.19.0</elasticsearch.version>
- <milvus.version>2.6.18</milvus.version>
- <openai.version>4.8.0</openai.version>
- <anthropic.version>2.12.0</anthropic.version>
- <aws.sdk.version>2.41.22</aws.sdk.version>
- <google.genai.version>1.56.0</google.genai.version>
- </properties>
-
<modules>
- <module>chat-models</module>
- <module>embedding-models</module>
- <module>vector-stores</module>
- <module>mcp</module>
+ <module>otel</module>
</modules>
</project>
diff --git a/integrations/pom.xml b/integrations/pom.xml
index e691f2508..aa87c4e22 100644
--- a/integrations/pom.xml
+++ b/integrations/pom.xml
@@ -38,6 +38,7 @@ under the License.
<anthropic.version>2.12.0</anthropic.version>
<aws.sdk.version>2.41.22</aws.sdk.version>
<google.genai.version>1.56.0</google.genai.version>
+ <opentelemetry.version>1.51.0</opentelemetry.version>
</properties>
<modules>
@@ -45,6 +46,7 @@ under the License.
<module>embedding-models</module>
<module>vector-stores</module>
<module>mcp</module>
+ <module>observability</module>
</modules>
</project>