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]
