sangkyoonnam commented on code in PR #1160:
URL: https://github.com/apache/flink-agents/pull/1160#discussion_r4111229456


##########
integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/EventLogOTelExporter.java:
##########
@@ -0,0 +1,333 @@
+/*
+ * 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.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.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.concurrent.TimeUnit;
+
+/**
+ * Exports Agent Trace Event Logs as OpenTelemetry GenAI traces.
+ *
+ * <p>This is the out-of-band exporter agreed in the Agent Trace design 
discussions: it reads the
+ * Event Log written by the File/SLF4J event loggers (with {@code 
event-log.trace.enabled: true}),
+ * assembles spans per {@link AgentTraceSpans}, and pushes them to any OTLP 
endpoint. 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 
Event Logger, and also
+ * its pretty-printed variant (the parser consumes concatenated JSON objects 
regardless of line
+ * breaks).
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * EventLogOTelExporter exporter =
+ *     EventLogOTelExporter.builder()
+ *             .setEndpoint("http://localhost:4317";)
+ *             .setProtocol("grpc")
+ *             .setServiceName("my-agent-job")
+ *             .build();
+ * 
exporter.exportFiles(List.of(Path.of("/tmp/flink-agents/events-<jobId>-<task>-0.log")));
+ * exporter.shutdown();
+ * }</pre>
+ */
+public class EventLogOTelExporter implements AutoCloseable {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(EventLogOTelExporter.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;
+
+    private final AgentTraceSpans assembler;
+    private final SpanExporter spanExporter;
+
+    EventLogOTelExporter(String serviceName, SpanExporter spanExporter) {
+        this.assembler = new AgentTraceSpans(serviceName);
+        this.spanExporter = spanExporter;
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /**
+     * Reads the given Event Log files and exports the assembled spans.
+     *
+     * <p>A directory argument is expanded to the {@code events-*.log} files 
it contains (the
+     * FileEventLogger naming contract, matching {@code trace_tree.py}); a 
file argument is consumed
+     * as-is regardless of its name, so records collected from any Event 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 =
+                            
MAPPER.readerFor(TraceRecord.class).readValues(in)) {
+                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(
+                                new ConverterDiagnostic(
+                                        ConverterDiagnostic.MALFORMED_RECORD,
+                                        null,
+                                        "Could not decode an Event Log record: 
" + e.getMessage(),
+                                        file.toString()));
+                        malformedInFile++;
+                        fileAborted = malformedInFile >= 
MAX_MALFORMED_PER_FILE || !canContinue(it);
+                    }
+                }
+            }
+        }
+        return export(records, diagnostics);
+    }
+
+    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)) {
+                List<Path> discovered = new ArrayList<>();
+                try (DirectoryStream<Path> stream =
+                        Files.newDirectoryStream(path, "events-*.log")) {
+                    stream.forEach(discovered::add);
+                }
+                Collections.sort(discovered);
+                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);
+        if (!spans.isEmpty()) {
+            CompletableResultCode result = spanExporter.export(spans);

Review Comment:
   This exports every assembled span in one call. I checked the pinned 
OpenTelemetry 1.51.0 gRPC and HTTP exporters: neither splits the collection, 
each marshals it into a single request. The OTel Collector's default gRPC 
receive limit is 4 MiB (grpc-go's default unless `max_recv_msg_size_mib` is 
set), so an oversized export is rejected as a whole. Could exports use bounded 
batches and report partial progress if a later batch fails? The SDK's 512-span 
`BatchSpanProcessor` default is one reference point, though a span count alone 
doesn't guarantee a request stays under the byte limit. If batching is added, 
should the 30 s wait at L170 apply per batch or to the whole run?



##########
integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpans.java:
##########
@@ -0,0 +1,427 @@
+/*
+ * 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.ExecutionLifecycleEvents;
+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 java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Assembles OpenTelemetry spans from Agent Trace Event 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} (falling back to the run root);
+ *   <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 Event 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_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 String INSTRUMENTATION_SCOPE_NAME = 
"org.apache.flink.agents.otel";
+
+    private final Resource resource;
+    private final InstrumentationScopeInfo scope;
+
+    public AgentTraceSpans(String serviceName) {
+        this.resource =
+                Resource.getDefault().toBuilder()
+                        .put(AttributeKey.stringKey("service.name"), 
serviceName)
+                        .build();
+        this.scope = 
InstrumentationScopeInfo.create(INSTRUMENTATION_SCOPE_NAME);
+    }
+
+    /** Assembles spans from Event 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) 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) {
+            String eventType = record.getEventType();
+            if (eventType == null
+                    || 
!ExecutionLifecycleEvents.isExecutionLifecycleEvent(eventType)) {
+                continue;
+            }
+            if (record.getExecutionId() == null
+                    || record.getInputRunId() == null
+                    || record.getTimestamp() == null) {
+                continue;
+            }
+            executions
+                    .computeIfAbsent(record.getExecutionId(), id -> new 
ExecutionSpanBuilder())
+                    .accept(record);
+            runs.computeIfAbsent(record.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, 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);
+        }
+        if (run.businessKey != null) {
+            attributes.put(GEN_AI_CONVERSATION_ID, 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, List<ConverterDiagnostic> 
diagnostics) {
+        TraceRecord any = execution.anyRecord();
+        String inputRunId = any.getInputRunId();
+        String entityType = any.getEntityType();
+        String entityName = any.getEntityName() != null ? any.getEntityName() 
: "unknown";
+
+        AttributesBuilder attributes = Attributes.builder();
+        attributes.put(FA_INPUT_RUN_ID, inputRunId);
+        attributes.put(FA_EXECUTION_ID, any.getExecutionId());
+        if (entityType != null) {
+            attributes.put(FA_ENTITY_TYPE, entityType);
+        }
+        attributes.put(FA_ENTITY_NAME, entityName);
+        if (any.getBusinessKey() != null) {
+            attributes.put(GEN_AI_CONVERSATION_ID, 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);
+            // Prefer the provider-issued call id, which is what the model's 
tool-call request
+            // carries; the framework-assigned id is the fallback.
+            String callId = stringValue(metadata, 
ToolExecutionMetadataKeys.EXTERNAL_ID);
+            if (callId == null) {
+                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.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 != null ? entityType + " " : "") + entityName;
+            kind = SpanKind.INTERNAL;
+        }
+
+        StatusData status = StatusData.unset();
+        TraceRecord terminal = execution.terminal;
+        if (terminal != null) {
+            if 
(ExecutionLifecycleEvents.STATUS_FAILED.equals(terminal.getStatus())) {
+                status =
+                        StatusData.create(
+                                StatusCode.ERROR,
+                                terminal.getErrorMessage() != null
+                                        ? terminal.getErrorMessage()
+                                        : "");
+                String errorType =
+                        terminal.getErrorType() != null
+                                ? terminal.getErrorType()
+                                : terminal.getProblemCategory();
+                if (errorType != null) {
+                    attributes.put(ERROR_TYPE, errorType);
+                }
+            }
+            if (terminal.getStatus() != null) {
+                attributes.put(FA_EXECUTION_STATUS, terminal.getStatus());
+            }
+            putUsageIfPresent(attributes, terminal.getEventAttributes());
+        }
+        boolean reused =
+                terminal != null
+                        && 
ExecutionLifecycleEvents.STATUS_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;
+
+        SpanContext parent =
+                any.getParentExecutionId() != null
+                        ? spanContext(inputRunId, 
OTelIds.spanId(any.getParentExecutionId()))
+                        : spanContext(inputRunId, 
OTelIds.runRootSpanId(inputRunId));
+
+        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 static String stringValue(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private static void putUsageIfPresent(
+            AttributesBuilder attributes, Map<String, Object> eventAttributes) 
{
+        if (eventAttributes == null) {
+            return;
+        }
+        Object prompt = eventAttributes.get("promptTokens");
+        if (prompt instanceof Number) {
+            attributes.put(GEN_AI_USAGE_INPUT_TOKENS, ((Number) 
prompt).longValue());
+        }
+        Object completion = eventAttributes.get("completionTokens");
+        if (completion instanceof Number) {
+            attributes.put(GEN_AI_USAGE_OUTPUT_TOKENS, ((Number) 
completion).longValue());
+        }
+    }
+
+    private static SpanContext spanContext(String inputRunId, String 
spanIdHex) {
+        return SpanContext.create(
+                OTelIds.traceId(inputRunId),
+                spanIdHex,
+                TraceFlags.getSampled(),
+                TraceState.getDefault());
+    }
+
+    private static long epochNanos(String isoTimestamp) {
+        Instant instant = Instant.parse(isoTimestamp);

Review Comment:
   nit: an otherwise valid lifecycle record with an invalid timestamp throws 
`DateTimeParseException` through `RunAccumulator.accept` and aborts the whole 
export, while a JSON decoding failure becomes a `MALFORMED_RECORD` diagnostic. 
`EventContext` writes `Instant.now().toString()` on the built-in path, but the 
other constructor and the timestamped report methods take the string as given. 
Could the timestamp be validated before the record enters either accumulator, 
with a diagnostic and skip instead? A test with one invalid timestamp followed 
by a valid record would cover it.



##########
integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpans.java:
##########
@@ -0,0 +1,427 @@
+/*
+ * 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.ExecutionLifecycleEvents;
+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 java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Assembles OpenTelemetry spans from Agent Trace Event 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} (falling back to the run root);
+ *   <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 Event 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_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 String INSTRUMENTATION_SCOPE_NAME = 
"org.apache.flink.agents.otel";
+
+    private final Resource resource;
+    private final InstrumentationScopeInfo scope;
+
+    public AgentTraceSpans(String serviceName) {
+        this.resource =
+                Resource.getDefault().toBuilder()
+                        .put(AttributeKey.stringKey("service.name"), 
serviceName)
+                        .build();
+        this.scope = 
InstrumentationScopeInfo.create(INSTRUMENTATION_SCOPE_NAME);
+    }
+
+    /** Assembles spans from Event 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) 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) {
+            String eventType = record.getEventType();
+            if (eventType == null
+                    || 
!ExecutionLifecycleEvents.isExecutionLifecycleEvent(eventType)) {
+                continue;
+            }
+            if (record.getExecutionId() == null
+                    || record.getInputRunId() == null
+                    || record.getTimestamp() == null) {
+                continue;
+            }
+            executions
+                    .computeIfAbsent(record.getExecutionId(), id -> new 
ExecutionSpanBuilder())
+                    .accept(record);
+            runs.computeIfAbsent(record.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, 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);
+        }
+        if (run.businessKey != null) {
+            attributes.put(GEN_AI_CONVERSATION_ID, 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, List<ConverterDiagnostic> 
diagnostics) {
+        TraceRecord any = execution.anyRecord();
+        String inputRunId = any.getInputRunId();
+        String entityType = any.getEntityType();
+        String entityName = any.getEntityName() != null ? any.getEntityName() 
: "unknown";
+
+        AttributesBuilder attributes = Attributes.builder();
+        attributes.put(FA_INPUT_RUN_ID, inputRunId);
+        attributes.put(FA_EXECUTION_ID, any.getExecutionId());
+        if (entityType != null) {
+            attributes.put(FA_ENTITY_TYPE, entityType);
+        }
+        attributes.put(FA_ENTITY_NAME, entityName);
+        if (any.getBusinessKey() != null) {
+            attributes.put(GEN_AI_CONVERSATION_ID, any.getBusinessKey());

Review Comment:
   `businessKey` is the keyed-stream key rendered as text. It is a conversation 
in a chat pipeline and an order or device id elsewhere, while the convention 
asks for a conversation identifier the library actually has. Would you carry it 
as `flink_agents.business_key` unconditionally and map it to 
`gen_ai.conversation.id` behind an option? Same for the root mapping at L163.



##########
integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpans.java:
##########
@@ -0,0 +1,427 @@
+/*
+ * 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.ExecutionLifecycleEvents;
+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 java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Assembles OpenTelemetry spans from Agent Trace Event 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} (falling back to the run root);
+ *   <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 Event 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_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 String INSTRUMENTATION_SCOPE_NAME = 
"org.apache.flink.agents.otel";
+
+    private final Resource resource;
+    private final InstrumentationScopeInfo scope;
+
+    public AgentTraceSpans(String serviceName) {
+        this.resource =
+                Resource.getDefault().toBuilder()
+                        .put(AttributeKey.stringKey("service.name"), 
serviceName)
+                        .build();
+        this.scope = 
InstrumentationScopeInfo.create(INSTRUMENTATION_SCOPE_NAME);
+    }
+
+    /** Assembles spans from Event 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) 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) {
+            String eventType = record.getEventType();
+            if (eventType == null
+                    || 
!ExecutionLifecycleEvents.isExecutionLifecycleEvent(eventType)) {
+                continue;
+            }
+            if (record.getExecutionId() == null
+                    || record.getInputRunId() == null
+                    || record.getTimestamp() == null) {
+                continue;
+            }
+            executions
+                    .computeIfAbsent(record.getExecutionId(), id -> new 
ExecutionSpanBuilder())
+                    .accept(record);
+            runs.computeIfAbsent(record.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, 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);
+        }
+        if (run.businessKey != null) {
+            attributes.put(GEN_AI_CONVERSATION_ID, 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, List<ConverterDiagnostic> 
diagnostics) {
+        TraceRecord any = execution.anyRecord();
+        String inputRunId = any.getInputRunId();
+        String entityType = any.getEntityType();
+        String entityName = any.getEntityName() != null ? any.getEntityName() 
: "unknown";
+
+        AttributesBuilder attributes = Attributes.builder();
+        attributes.put(FA_INPUT_RUN_ID, inputRunId);
+        attributes.put(FA_EXECUTION_ID, any.getExecutionId());
+        if (entityType != null) {
+            attributes.put(FA_ENTITY_TYPE, entityType);
+        }
+        attributes.put(FA_ENTITY_NAME, entityName);
+        if (any.getBusinessKey() != null) {
+            attributes.put(GEN_AI_CONVERSATION_ID, 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);

Review Comment:
   nit: the `execute_tool` table lists `gen_ai.agent.name` as conditionally 
required when applicable, and `TraceRecord` exposes `agentName`, but it's only 
set on the root span. Could tool spans carry it when the record has one? That 
keeps a tool span self-describing when a backend shows it without its root.



##########
integrations/observability/otel/src/main/java/org/apache/flink/agents/integrations/observability/otel/AgentTraceSpans.java:
##########
@@ -0,0 +1,427 @@
+/*
+ * 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.ExecutionLifecycleEvents;
+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 java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Assembles OpenTelemetry spans from Agent Trace Event 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} (falling back to the run root);
+ *   <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 Event 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_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 String INSTRUMENTATION_SCOPE_NAME = 
"org.apache.flink.agents.otel";
+
+    private final Resource resource;
+    private final InstrumentationScopeInfo scope;
+
+    public AgentTraceSpans(String serviceName) {
+        this.resource =
+                Resource.getDefault().toBuilder()
+                        .put(AttributeKey.stringKey("service.name"), 
serviceName)
+                        .build();
+        this.scope = 
InstrumentationScopeInfo.create(INSTRUMENTATION_SCOPE_NAME);
+    }
+
+    /** Assembles spans from Event 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) 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) {
+            String eventType = record.getEventType();
+            if (eventType == null
+                    || 
!ExecutionLifecycleEvents.isExecutionLifecycleEvent(eventType)) {
+                continue;
+            }
+            if (record.getExecutionId() == null
+                    || record.getInputRunId() == null
+                    || record.getTimestamp() == null) {
+                continue;
+            }
+            executions
+                    .computeIfAbsent(record.getExecutionId(), id -> new 
ExecutionSpanBuilder())
+                    .accept(record);
+            runs.computeIfAbsent(record.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, 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);
+        }
+        if (run.businessKey != null) {
+            attributes.put(GEN_AI_CONVERSATION_ID, 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, List<ConverterDiagnostic> 
diagnostics) {
+        TraceRecord any = execution.anyRecord();
+        String inputRunId = any.getInputRunId();
+        String entityType = any.getEntityType();
+        String entityName = any.getEntityName() != null ? any.getEntityName() 
: "unknown";
+
+        AttributesBuilder attributes = Attributes.builder();
+        attributes.put(FA_INPUT_RUN_ID, inputRunId);
+        attributes.put(FA_EXECUTION_ID, any.getExecutionId());
+        if (entityType != null) {
+            attributes.put(FA_ENTITY_TYPE, entityType);
+        }
+        attributes.put(FA_ENTITY_NAME, entityName);
+        if (any.getBusinessKey() != null) {
+            attributes.put(GEN_AI_CONVERSATION_ID, 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);
+            // Prefer the provider-issued call id, which is what the model's 
tool-call request
+            // carries; the framework-assigned id is the fallback.
+            String callId = stringValue(metadata, 
ToolExecutionMetadataKeys.EXTERNAL_ID);
+            if (callId == null) {
+                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.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 != null ? entityType + " " : "") + entityName;
+            kind = SpanKind.INTERNAL;
+        }
+
+        StatusData status = StatusData.unset();
+        TraceRecord terminal = execution.terminal;
+        if (terminal != null) {
+            if 
(ExecutionLifecycleEvents.STATUS_FAILED.equals(terminal.getStatus())) {
+                status =
+                        StatusData.create(
+                                StatusCode.ERROR,
+                                terminal.getErrorMessage() != null
+                                        ? terminal.getErrorMessage()
+                                        : "");
+                String errorType =
+                        terminal.getErrorType() != null
+                                ? terminal.getErrorType()
+                                : terminal.getProblemCategory();
+                if (errorType != null) {
+                    attributes.put(ERROR_TYPE, errorType);
+                }
+            }
+            if (terminal.getStatus() != null) {
+                attributes.put(FA_EXECUTION_STATUS, terminal.getStatus());
+            }
+            putUsageIfPresent(attributes, terminal.getEventAttributes());
+        }
+        boolean reused =
+                terminal != null
+                        && 
ExecutionLifecycleEvents.STATUS_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;
+
+        SpanContext parent =
+                any.getParentExecutionId() != null
+                        ? spanContext(inputRunId, 
OTelIds.spanId(any.getParentExecutionId()))
+                        : spanContext(inputRunId, 
OTelIds.runRootSpanId(inputRunId));
+
+        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 static String stringValue(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private static void putUsageIfPresent(
+            AttributesBuilder attributes, Map<String, Object> eventAttributes) 
{
+        if (eventAttributes == null) {
+            return;
+        }
+        Object prompt = eventAttributes.get("promptTokens");

Review Comment:
   `executionFinished()` produces no usage attributes: the Java and Python chat 
models keep token counts in the response's `extraArgs` and in metric counters 
(`BaseChatModelSetup.java:181`), and nothing writes `promptTokens` / 
`completionTokens` into a terminal record's `eventAttributes`. The branches 
here are exercised only by the test fixtures. The docs already defer richer 
usage metrics, so this is a clarification rather than a request to grow the PR: 
could the mapping docs say that logs from the current built-in reporters don't 
supply the terminal attributes this converter needs for `gen_ai.usage.*`, so 
"when recorded" doesn't read as "usually"?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to