Trace and Observability
This layer is not about "you can plug in an observability platform later." It is about what events ai4j-agent already emits today, how those events collapse into spans, which defaults affect what gets recorded, and the real boundary between it and OTel / Langfuse / FlowGram.
If you only understand trace as "print a request log," you will miss the most important parts of this design:
- The agent runtime already has a unified event surface.
- Trace is not hard-coded inside the runtime; it is a listener projection.
- Span types already cover model, tool, handoff, and team task.
- But it is still a lightweight implementation, not a full APM / distributed tracing platform.
1. Six key design decisions to grasp first
1.1 Trace is an event projection, not a data structure embedded in the runtime
BaseAgentRuntime, CodeActRuntime, SubAgentToolExecutor, and AgentTeamEventHook all emit AgentEvent.
What actually turns these events into TraceSpan is:
AgentTraceListener
This means the trace layer is fundamentally:
- runtime event bus -> listener -> exporter
Rather than the runtime assembling an OTel span directly on every step.
1.2 Setting traceConfig(...) alone does not enable trace
This is one of the most easily misunderstood default behaviors.
The actual logic inside AgentBuilder.build() is:
AgentEventPublisher resolvedEventPublisher = eventPublisher == null ? new AgentEventPublisher() : eventPublisher;
if (traceExporter != null) {
resolvedEventPublisher.addListener(new AgentTraceListener(traceExporter, traceConfig));
}
In other words:
- Only when
traceExporter != nulldoes the Builder automatically attachAgentTraceListener. - Passing only
traceConfig(...)does not take effect automatically.
So the minimum condition to enable trace is not traceConfig, but traceExporter.
1.3 The default is "record everything," not "mask by default"
The defaults of TraceConfig.builder().build() are:
recordModelInput = truerecordModelOutput = truerecordToolArgs = truerecordToolOutput = truerecordMetrics = truemaxFieldLength = 0masker = nullpricingResolver = null
So the default behavior leans toward R&D debugging rather than minimal production exposure.
If you turn this on in production without adding a masker / truncate, the prompt, tool args, and tool output may all be recorded in full.
1.4 Trace is best-effort by default and does not block the main flow
AgentEventPublisher.publish(...) swallows exceptions while iterating listeners:
try {
listener.onEvent(event);
} catch (Exception ignored) {
// Listener errors should not break agent execution.
}
So even if:
- the exporter fails to write a file,
- the trace listener throws internally,
- the OTel downstream errors out,
the main Agent run still continues.
This is good for production stability, but the trade-off is that you cannot treat trace success as a business success condition.
1.5 The current trace is a single-machine lightweight model, not a concurrency-isolated run-recording system
AgentTraceListener internally maintains a set of mutable fields:
traceIdrootSpanstepSpansmodelSpanstoolSpans
These fields are not bucketed by "runId / sessionId," and AgentEvent carries no run identifier, only:
typestepmessagepayload
This means the same trace listener on the same Agent instance is better suited for:
- a single run
- sequential execution
If multiple runs concurrently reuse the same listener, the traces may cross-contaminate.
1.6 OTel and Langfuse are wired as exporter bridges, not as native instrumentation
OpenTelemetryTraceExporter and LangfuseTraceExporter are both positioned to:
- First keep AI4J's own
TraceSpan - Then project to OTel span at the export stage
They do not rewrite BaseAgentRuntime into native OpenTelemetry instrumentation, nor do they handle cross-service context propagation.
2. The current trace object graph
The core objects on this line can first be compressed into one diagram:
AgentBuilder
-> AgentEventPublisher
-> AgentTraceListener
-> TraceExporter
AgentEvent
-> AgentTraceListener
-> TraceSpan / TraceSpanEvent / TraceMetrics
TraceExporter
-> ConsoleTraceExporter
-> InMemoryTraceExporter
-> JsonlTraceExporter
-> CompositeTraceExporter
-> OpenTelemetryTraceExporter
-> LangfuseTraceExporter
The responsibility boundaries are as follows:
| Object | Real responsibility |
|---|---|
AgentEventPublisher | Broadcasts runtime events |
AgentTraceListener | Folds events into spans |
TraceConfig | Decides recording granularity, masking, and truncation |
TraceSpan | Unified span data model |
TraceExporter | Decides where spans are output |
3. How trace is wired into the Agent
The minimum way to enable it is:
Agent agent = Agents.react()
.modelClient(modelClient)
.model("gpt-4.1")
.traceExporter(new ConsoleTraceExporter())
.build();
If you also need to control the recording policy:
TraceConfig traceConfig = TraceConfig.builder()
.maxFieldLength(4000)
.masker(text -> text == null ? null : text.replace("SECRET", "***"))
.build();
Agent agent = Agents.react()
.modelClient(modelClient)
.model("gpt-4.1")
.traceExporter(new JsonlTraceExporter("logs/agent-trace.jsonl"))
.traceConfig(traceConfig)
.build();
There are three points here that are easy to overlook:
traceExporter(...)is the switch.traceConfig(...)is only configuration, not a switch.- If you pass a custom
eventPublisher(...), the Builder adds the trace listener to your publisher rather than spinning up a separate one.
4. What is actually inside a TraceSpan
TraceSpan currently has only a few fields, but they are enough to cover the Agent's main path:
traceIdspanIdparentSpanIdnametypestatusstartTimeendTimeerrorattributeseventsmetrics
These three parts are the most worth understanding separately:
4.1 attributes
Suited for storing:
- prompt metadata
- tool args / output
- provider response metadata
- structured fields inside team / handoff payloads
4.2 events
Suited for storing:
- reasoning text
- retry information
- stream deltas
- team messages
In other words, not every intermediate step deserves its own span.
4.3 metrics
Currently all mounted together:
durationMillispromptTokenscompletionTokenstotalTokensinputCostoutputCosttotalCostcurrency
As long as recordMetrics = false, even durationMillis is not computed.
5. What span types actually represent
The current enum values of TraceSpanType include:
RUNSTEPMODELTOOLHANDOFFTEAM_TASKMEMORYAGENT_FLOWFLOWGRAM_TASKFLOWGRAM_NODE
But you need to distinguish "the type exists" from "the current AgentTraceListener will actually emit it."
What AgentTraceListener currently emits
RUNSTEPMODELTOOLHANDOFFTEAM_TASKMEMORY
Sources of the other types
AGENT_FLOW- comes from
AgentFlowTraceBridge
- comes from
FLOWGRAM_TASK/FLOWGRAM_NODE- used by the projection system on the FlowGram / AgentFlow side, not spans emitted directly by the current Agent runtime
6. The real mapping chain from events to spans
6.1 When the root run span starts
AgentTraceListener does not create the root span at the instant Agent.run(...) is called.
It does so only when the first STEP_START arrives:
- Generate
traceId - Create the root
RUNspan - Then create the current step span
So the starting point of the current run span is defined as:
- the first runtime step begins
Not:
- when the Agent object is created
- when the user request enters the API boundary
6.2 How a step span ends
Each STEP_START creates a step span.
The corresponding STEP_END will:
- end that step span
- clean up that step's tool span fallback records
If the root span has already ended at FINAL_OUTPUT, and the step spans are all cleared, the listener will reset().
6.3 The streaming vs. non-streaming difference for model spans
MODEL_REQUEST creates a MODEL span.
After that there are two paths:
Non-streaming
- The runtime sends the final
MODEL_RESPONSE(payload=rawResponse) - The listener uses the payload to fill in:
responseIdresponseModelfinishReasonusage
- Accumulates metrics
- Closes the model span
Streaming
- Intermediate deltas come through as
MODEL_RESPONSE(message=delta, payload=null) - The listener attaches them as
model.response.deltaevents - Until the final raw payload arrives
- Only then does it actually close the model span
So the current stream trace is not "one span per delta," but:
- one
MODELspan - several
model.response.deltaevents
6.4 Why reasoning and retry are not independent spans
Both MODEL_REASONING and MODEL_RETRY are attached as internal events of the current model span:
model.reasoningmodel.retry
This is reasonable, because they are essentially internal processes of the same model call rather than separate external operations.
6.5 When a tool span is created and ended
TOOL_CALL creates a tool span.
If the payload is an AgentToolCall and recordToolArgs = true, the attributes will include:
toolcallIdarguments
When TOOL_RESULT arrives, the listener will:
- look up the span by
callId - write output / result / stdout / error
- determine status from the payload
- end the tool span
6.6 Handoff and team task are not auxiliary messages, but independent spans
HANDOFF_START / HANDOFF_END are aggregated into one HANDOFF span.
TEAM_TASK_CREATED / TEAM_TASK_UPDATED / TEAM_MESSAGE are aggregated into a TEAM_TASK span plus internal events.
This means that in the observability layer:
- subagent handoff
- team task lifecycle
are no longer treated as ordinary tool messages, but as explicit first-class objects.
7. What fields the runtime records
7.1 Model input fields
When recordModelInput = true, common attributes of a MODEL span include:
modelsystemPromptinstructionsitemstoolstoolChoiceparallelToolCallstemperaturetopPmaxOutputTokensreasoningstorestreamuserextraBody
7.2 Model output fields
When recordModelOutput = true:
- stream increments go into the
model.response.deltaevent - the final payload goes into
output - the root run span writes
finalOutput
In addition, fields that can be parsed out of the provider payload:
responseIdresponseModelfinishReason
are also extracted into attributes separately.
7.3 Tool fields
When recordToolArgs = true, the tool span retains the arguments.
When recordToolOutput = true:
- a normal tool writes
output - a CodeAct tool result may write:
resultstdouterror
7.4 Metrics fields
As long as the response carries usage and recordMetrics = true, the listener will try to write:
- prompt tokens
- completion tokens
- total tokens
- duration
Only if you additionally configure a TracePricingResolver will it then estimate cost.
A minimal configuration example:
TraceConfig traceConfig = TraceConfig.builder()
.pricingResolver(model -> {
if ("gpt-4.1".equals(model)) {
return TracePricing.builder()
.inputCostPerMillionTokens(2.0D)
.outputCostPerMillionTokens(8.0D)
.currency("USD")
.build();
}
return null;
})
.build();
Agent agent = Agents.react()
.modelClient(modelClient)
.model("gpt-4.1")
.traceExporter(new JsonlTraceExporter("logs/agent-trace.jsonl"))
.traceConfig(traceConfig)
.build();
The price unit here is "per million tokens." The SDK does not ship a default price table, because model prices change frequently; if the resolver returns null, only tokens are recorded for that model, with no cost estimate.
8. Defaults and easily misjudged semantics
8.1 When pricingResolver = null, cost is not computed automatically
Even if the provider returns usage, by default you get only tokens, not cost.
8.2 maxFieldLength = 0 means no truncation
Many people mistakenly think 0 means "disable the field."
Here 0 means:
- no length trimming is performed
8.3 The order in safeValue(...) is: serialize -> mask -> truncate
A non-string object is first JSON-serialized, then handed to the masker, then truncated according to maxFieldLength.
This means the masker typically sees the final string, not the Java object structure.
8.4 recordMetrics = false also turns off duration
finishSpan(...) only back-fills durationMillis when recordMetrics = true.
So if you turn off metrics, not only do tokens / cost disappear, the duration is not written either.
9. Boundaries most worth knowing in the current implementation
9.1 The trace listener is better suited to sequential execution, not concurrent runs on the same instance
The current listener has no runId bucketing, and its internal state is not "a fresh copy per run."
So if you plan to:
- invoke the same Agent concurrently from multiple threads
- and share the same
AgentTraceListener
expect the traces to potentially cross wires.
9.2 Listener errors are swallowed by default
This is to avoid affecting the business, but it also means:
- the trace file was not written
- the exporter downstream went down
you may only notice missing data on the external platform, and you will not receive an exception inside the Agent run.
9.3 The MEMORY span is now supported by the listener, but the core memory implementation does not actively emit events
This is the one fact on this page most in need of correction.
Although AgentEventType has:
MEMORY_COMPRESS
and AgentTraceListener also maps it to a MEMORY span, the production code in the current repository does not actively emit such events; current hits come mainly from tests.
This means that by default:
- you will not automatically see memory compress traces in an ordinary Agent run
unless:
- you publish such events yourself
- or a later implementation adds memory compression event emission
9.4 The tool span fallback key is not a strongly consistent ID system
If callId is missing, the listener falls back to using:
step + ":" + toolName
or records a fallback tool span per step.
Under normal conditions the runtime tries to normalize tool calls, but this fallback is only a compatibility fallback, not a strongly constrained unique-key design.
9.5 The OTel exporter does not push parent-child chains in real time span by span
To recover parent-child relationships, AbstractOpenTelemetryTraceExporter puts finished spans into pendingSpans first.
Only when the parent span can also be exported are they actually sent out.
Since the root RUN span usually ends last, this means:
- many child spans are buffered first
- and flushed together after the root span is exported
So it is more like "export by trace completeness," not "report every span immediately the moment it ends."
10. The real applicability boundary of each exporter
10.1 ConsoleTraceExporter
Suited for:
- local development
- quickly seeing whether there are step / model / tool / handoff entries
Not suited for:
- archiving
- querying
- multi-run aggregate analysis
10.2 InMemoryTraceExporter
Suited for:
- unit tests
- integration test assertions
- local in-memory sampling
It just puts the spans into a list, with no indexing, no eviction, and no aggregation.
10.3 JsonlTraceExporter
Suited for:
- local archiving
- offline analysis
- importing into another system after debugging
The current behavior is:
- append to a file
- auto-create parent directories
- no rotation
- one JSON line per span
10.4 CompositeTraceExporter
Suited for:
- writing to the console, to a file, and to a platform at the same time
It only fans out; it does not do advanced governance beyond a failure-isolation policy.
10.5 OpenTelemetryTraceExporter
Suited for:
- when you already have an OTel collector / OTLP pipeline
- and want to bring AI4J traces into your existing observability system
It writes:
ai4j.trace_idai4j.span_idai4j.parent_span_idai4j.span_typeai4j.span_statusai4j.errorai4j.attr.*ai4j.metrics.*ai4j.event.*gen_ai.usage.input_tokensgen_ai.usage.output_tokens
But remember, it is a projection bridge and does not handle full distributed tracing semantics.
10.6 LangfuseTraceExporter
Suited for:
- when you already go through the OTel pipeline
- but want the upstream observability platform to hook directly into Langfuse
It does not directly connect to the Langfuse private SDK; instead it:
- builds on the OTel exporter
- additionally writes Langfuse-recognizable attributes
For example:
langfuse.observation.typelangfuse.observation.levellangfuse.observation.inputlangfuse.observation.outputlangfuse.observation.modellangfuse.observation.model_parameterslangfuse.observation.usage_detailslangfuse.observation.cost_detailslangfuse.observation.metadatalangfuse.trace.namelangfuse.trace.outputlangfuse.trace.metadata
11. The two things you must at least do for production
If you bring trace to production, you should at least add these two:
11.1 Masking
TraceConfig traceConfig = TraceConfig.builder()
.masker(text -> text == null
? null
: text.replaceAll("(?i)api[_-]?key\\s*[:=]\\s*[^,\\s]+", "apiKey=***"))
.build();
11.2 Truncation
TraceConfig traceConfig = TraceConfig.builder()
.maxFieldLength(4000)
.build();
Otherwise, fields like prompt, tool output, and extraBody can easily blow up the trace volume.
12. Relationship with AgentFlow / FlowGram
You must keep this layer separate.
The Agent runtime line
- The event source is
AgentEvent - The projector is
AgentTraceListener - Span types are mainly
RUN / STEP / MODEL / TOOL / HANDOFF / TEAM_TASK
The AgentFlow / FlowGram line
- The event source is not
AgentEvent - but rather
AgentFlowTraceContext+ chat/workflow events - The projector is
AgentFlowTraceBridge - The main span type leans toward
AGENT_FLOW
So the FlowGram front-end view should not be taken as directly equivalent to the Agent runtime trace.
More precisely:
- Agent trace is the backend run view
- FlowGram trace projection is the canvas and node view
If you are looking at FlowGram integration, continue reading Flowgram Runtime.
13. How to read trace when troubleshooting
Recommended order:
RUNLook at the overall status, total duration, and final output.STEPLook for abnormal loops, or whether the step count grows unexpectedly.MODELLook at prompt, response, reasoning, retry, and usage.TOOLLook at whether arguments, output, and errors are reasonable.HANDOFFLook at whether a subagent handoff happened, and at which layer it failed.TEAM_TASKLook at whether the team task board is stuck in some state.
If your first instinct is to drill straight into the provider raw payload, it will usually be much slower.
14. Recommended source-reading order
ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentEvent.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentEventType.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentEventPublisher.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentBuilder.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/AgentTraceListener.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceConfig.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/AbstractOpenTelemetryTraceExporter.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/OpenTelemetryTraceSupport.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/LangfuseTraceExporter.javaai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/AgentFlowTraceBridge.java