Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Chapter 19 — Observability in Production
Promise: By the end of this chapter you will know how to see what a BLOGE graph is doing in production — through listeners, interceptors, metrics, tracing, structured logging, context propagation, and audit journaling — without changing the workflow logic itself.
Learning Goals
- Explain the two main observability extension points:
ExecutionListenerfor lifecycle events andOperatorInterceptorfor around-invocation behavior. - Capture graph and node lifecycle signals with a custom listener and know which of the fine-grained sub-interfaces to implement.
- Wire
MetricsExecutionListener,TracingOperatorInterceptor, andLoggingExecutionListenerinto aGraphEnginecorrectly. - Use
CallerContextCarrierto propagate MDC or OpenTelemetry context into the engine's virtual threads. - Recognize when aggregate metrics are enough and when you need the structured,
per-event fidelity of
AuditJournalListener.
Prerequisites
- Chapter 6 — Resilience by Design — retry, timeout, and fallback events matter only if you already understand the resilience surface they describe.
- Chapter 7 — Designing Good Operators — interceptors wrap operator execution, so you need the operator contract first.
- Chapter 18 — Testing Your Graphs — this chapter builds directly on the same assertion and listener surfaces you used in tests.
Source Examples
| File | What it shows |
|---|---|
GraphEngineListenerTest.java | Lifecycle callbacks and listener exception isolation |
ResilientOperatorWrapperObservabilityTest.java | Retry, timeout, and fallback events |
MetricsExecutionListenerTest.java | Micrometer timers and counters |
TracingOperatorInterceptorTest.java | Graph and node spans |
AuditJournalListenerTest.java | Structured audit entries and event coverage |
BlogeObservabilityAutoConfiguration.java | Spring Boot observability auto-configuration |
Why This Matters
A graph that runs correctly on your machine today can still fail silently in production:
- A node starts throwing after a dependency upgrade. Without metrics you won't notice until a customer complains.
- A retry loop burns 30 seconds per request. Without per-node timing data you can't identify the bottleneck.
- A branch change skips a node that used to run. Without lifecycle signals, the system looks idle when it is actually making the wrong choice.
BLOGE separates concerns: the engine runs nodes, and extension hooks observe what happened. That separation is what makes production visibility possible without forcing metrics, tracing, or logging logic into every operator.
One Incident, One Execution ID, Four Projections
At 10:03, the order dashboard reports a latency spike. The failing request is
executionId=exec-order-42; its chargePayment node retries twice and then
uses a fallback. Keep that identity fixed while changing the question:
| Projection | Best question | Observation for exec-order-42 | What it cannot establish alone |
|---|---|---|---|
| Metric | Is this widespread? | retry count and p95 latency rose | Which exact attempt failed |
| Trace | Where was time spent? | chargePayment child span dominates | The durable event sequence |
| Structured log | What did the runtime report? | timeout token plus execution/node IDs | A complete, ordered history |
| Audit/event journal | What happened in order? | start → retry → retry → fallback → complete | Fleet-wide rate without aggregation |
The correlation key is the spine, not a fifth observability product. A metric starts the investigation; the same execution ID narrows the trace and logs; the journal reconstructs order. This incident is a teaching reconstruction from source-tested event types, not a captured production outage.
Mental Model
Think of the engine as a stage with fixed spotlight positions. Every node execution walks through the same spotlight sequence:
If you know which spotlight position you need, you know where to plug in your observability feature.
First Working Example
A minimal production-style wiring combines a listener and an interceptor around an otherwise ordinary engine:
var meterRegistry = new SimpleMeterRegistry();
var metrics = new MetricsExecutionListener(meterRegistry, "bloge");
var tracing = new TracingOperatorInterceptor(tracer);
var engine = GraphEngine.builder()
.registry(registry)
.listeners(List.of(metrics, tracing))
.interceptors(List.of(tracing))
.build();
That one builder call gives you three different views of the same execution:
- graph/node duration metrics,
- graph and node tracing spans, and
- the raw lifecycle event stream that powers both.
The important detail is not the specific library. It is the contract: listeners observe events; interceptors wrap execution.
Break It Apart
ExecutionListener — the event backbone
ExecutionListener
is a composite interface that extends seven fine-grained sub-interfaces:
| Sub-interface | Callbacks |
|---|---|
GraphLifecycleListener | onGraphStart, onGraphComplete |
NodeLifecycleListener | onNodeStart, onNodeComplete, onNodeFailed, onNodeSkipped, onNodeSuspended, onNodeResumed |
ResilienceListener | onNodeRetry, onNodeTimeout, onNodeFallback |
StreamingListener | onNodeStreamStart, onNodeStreamChunk, onNodeStreamEnd, onNodeStreamError |
IterationLifecycleListener | Loop/foreach iteration events |
TimerEventListener | Timer-fired events |
EventCorrelationListener | Event-matching events |
New integrations can implement just the narrowest sub-interface they need.
Existing code can continue implementing the aggregate ExecutionListener.
Safety guarantee: A throwing listener does not break graph execution. The engine catches listener exceptions and continues with the remaining listeners. This is tested in
GraphEngineListenerTest.listener_exceptionInListener_doesNotBreakExecution.
OperatorInterceptor — the AOP layer
OperatorInterceptor
wraps every operator call. The interceptor receives an
OperatorInvocation
and must call invocation.proceed() to continue the chain (or
short-circuit by returning a value directly).
public interface OperatorInterceptor {
Object intercept(OperatorInvocation invocation) throws Exception;
}
This is how TracingOperatorInterceptor creates child spans per node —
it starts a span before proceed() and ends it in the finally block.
CallerContextCarrier — context across virtual threads
The engine runs each node on a virtual thread. Thread-local state like
OpenTelemetry context or SLF4J MDC does not propagate automatically.
CallerContextCarrier
bridges this gap with a three-phase contract:
capture()— called on the caller thread, snapshots the context.Snapshot.attach()— called on the virtual thread, restores it.Scope.close()— called when the node finishes, cleans up.Scope.NOOPis available for carriers that need no cleanup.
Two implementations ship in bloge-metrics-otel:
OtelContextCarrier— propagatesio.opentelemetry.context.ContextMdcContextCarrier— propagates SLF4J MDC maps
Production observability components
The bloge-metrics-otel module
provides three ready-to-use components:
MetricsExecutionListener — emits Micrometer timers and counters:
bloge.graph.duration— Timer, taggedgraph+outcomebloge.node.duration— Timer, taggedgraph+node+outcomebloge.node.errors— Counter, tagged by exception classbloge.node.retries,bloge.node.timeouts,bloge.node.fallbacks,bloge.node.skipped— Countersbloge.stream.chunk.count,bloge.stream.duration,bloge.stream.errors— Streaming metrics
All names use a configurable prefix (default bloge).
Source: MetricsExecutionListener.java
TracingOperatorInterceptor — creates a parent span
(bloge.graph.execute) on graph start and a child span
(bloge.node.execute) around each operator invocation. It implements
both OperatorInterceptor and ExecutionListener — register the
same instance in both the interceptors and listeners lists.
Source: TracingOperatorInterceptor.java
LoggingExecutionListener — structured SLF4J logs with
bloge.graph, bloge.node, and bloge.executionId MDC keys. Two
optional flags — includeInput and includeOutput — control whether
payloads appear in logs. Keep both disabled unless you have reviewed
payloads for sensitive data.
Source: LoggingExecutionListener.java
Audit journal
For compliance-grade event recording, bloge-runtime-spi includes
AuditJournalListener.
It captures NODE_START, NODE_COMPLETE, NODE_FAILED, NODE_RETRY,
NODE_SUSPEND, and NODE_RESUME events as structured
AuditEntry
records and writes them to an
AuditJournalStore.
An asynchronous flush mode (virtual-thread worker + configurable batch
size) keeps the hot path lightweight.
AuditJournalListener accepts an optional TimeSource parameter so that
audit timestamps are derived from the same clock the engine uses. In tests
you can inject ManualTimeSource to get deterministic audit entries; in
production the default SystemTimeSource.INSTANCE is used.
// Production — wall clock
var listener = new AuditJournalListener(store, config, jsonCodec);
// Test — deterministic time
var listener = new AuditJournalListener(store, config, jsonCodec, manualTimeSource);
AuditConfig
controls: captureInput, captureOutput, asyncFlush,
flushInterval (default 100ms), batchSize (default 64).
ExecutionEventStore — tenant-scoped queries and purging
The ExecutionEventStore
interface exposes several methods that matter for production observability
pipelines:
| Method | Purpose |
|---|---|
loadEventsByTenant(tenantId, namespace, from, to, page, size) | Page through events scoped to one tenant/namespace within a time range |
countEventsByTenant(tenantId, namespace) | Count total events for a tenant — useful for dashboard metrics |
purgeEventsBefore(cutoff) | Delete events older than a cutoff instant |
purgeEventsBefore(cutoff, limit) | Batch-aware purge to avoid long-running delete transactions |
purgeExecutionEvents(executionId) | Delete all events for a single execution (e.g. after archival) |
All new methods have default implementations that throw
UnsupportedOperationException, so existing store implementations
continue to compile. Upgrade them incrementally as your observability
pipeline needs these capabilities.
Read Stable Log Tokens by Severity
Metrics and traces tell you that something is slow. Stable log tokens
identify a specific runtime or DSL fallback without making the surrounding
prose part of the contract. Alert policy belongs on the emitted severity and
operational impact; FINE/FINER tokens are diagnostic breadcrumbs, not
alarms by themselves.
| Token | Severity | What it means | First step |
|---|---|---|---|
[TIMER_RESTORE_FAILED] | SEVERE | TimerManager could not reload active durable timers; persisted timers will not fire until the next restart. | Inspect the attached exception, durable timer context, store connectivity, and the records being loaded. |
[STREAMING_ITEM_ERROR_SEND_FAILED] | WARNING | A streaming foreach item failed and the runtime could not emit its error chunk because the output channel also rejected the send. | Inspect the original item failure, channel-close race, and selected item-failure policy. |
[SCHEMA_NUMBER_PARSE_FALLBACK] | FINER | Number coercion is trying a wider numeric type after an integer or long parse miss. | Use for coercion diagnosis; alert only on a separately observed business or error-rate impact. |
[ACCESSOR_METHOD_LOOKUP_MISS] | FINER | A direct method accessor was absent, so DSL path access is trying a JavaBean getter fallback. | Confirm the property/getter contract when path resolution later fails. |
[SCHEMA_ENRICH_SKIPPED] | FINE | Operator metadata lookup failed during DSL schema enrichment; compilation continues with reduced enrichment. | Inspect the attached cause and registry metadata if diagnostics lose schema detail. |
[FINGERPRINT_SKIPPED] | FINE | The DSL compiler could not compute an Operator fingerprint and continues without it. | Inspect Operator registration and fingerprint support before relying on mismatch detection during recovery. |
These tokens are stable; the surrounding text is not. Match the bracketed token, not the prose.
Execution Event Journal
EventJournalListener is a built-in ExecutionListener that records
every engine-level event into an append-only journal. Unlike the
audit journal (which captures state changes for durability) or the
operator interceptor chain (which wraps single node calls), the event
journal is your replayable record of an execution.
ExecutionEventJournal journal = new InMemoryExecutionEventJournal();
GraphEngine engine = GraphEngine.builder()
.registry(registry)
.listeners(List.of(new EventJournalListener(journal)))
.build();
Each entry is an ExecutionEvent carrying an execution id, monotonic
sequence number, timestamp, and one of these types:
| Type | Emitted when |
|---|---|
GRAPH_STARTED / GRAPH_COMPLETED / GRAPH_FAILED | Engine enters/leaves a graph. |
NODE_STARTED / NODE_COMPLETED / NODE_FAILED / NODE_SKIPPED | Per node lifecycle. |
FOREACH_BATCH_STARTED / FOREACH_BATCH_COMPLETED | Each batch of a foreach. |
STREAM_ITEM_EMITTED / STREAM_COMPLETED | Streaming sources. |
WAIT_FOR_TIMER_ARMED / WAIT_FOR_TIMER_FIRED | Durable timer lifecycle. |
LEASE_ACQUIRED / LEASE_REFRESHED / LEASE_LOST | Durable lease lifecycle. |
Agent events — AgentEventJournalBridge
When you use the bloge-agent-ext module, an AgentEventJournalBridge
turns agent-internal lifecycle events into entries on the same
journal. This gives you a unified replay timeline of "graph turn + agent
turn + tool call":
GraphEngine engine = GraphEngine.builder()
.registry(registry)
.listeners(List.of(
new EventJournalListener(journal),
new AgentEventJournalBridge(journal))) // route agent events too
.build();
Agent event types added by the bridge:
| Type | Meaning |
|---|---|
AGENT_TURN_STARTED / AGENT_TURN_COMPLETED | Agent's reasoning loop iteration. |
AGENT_LLM_CALLED | LLM request sent (carries token usage and model id). |
AGENT_TOOL_INVOKED / AGENT_TOOL_COMPLETED / AGENT_TOOL_FAILED | Tool/function call lifecycle. |
AGENT_MEMORY_TRIMMED | A memory strategy dropped/summarised earlier turns. |
AGENT_STREAM_CHUNK_EMITTED | A streaming agent produced a chunk. |
A common pattern is to keep an in-memory journal for short-lived
debugging and ship a JdbcExecutionEventJournal to your warehouse for
long-term replay.
Common Trap
❌ Registering TracingOperatorInterceptor only as a listener
var tracing = new TracingOperatorInterceptor(tracer);
var engine = GraphEngine.builder()
.registry(registry)
.listeners(List.of(tracing)) // graph span ✅
// missing .interceptors(List.of(tracing)) — no node spans! ❌
.build();
TracingOperatorInterceptor wears two hats. The ExecutionListener side
creates the graph-level span in onGraphStart / onGraphComplete.
The OperatorInterceptor side creates node-level child spans in
intercept(). If you only register it as a listener, you will see a
single flat span per execution with no per-node breakdown.
Fix: register the same instance in both lists.
Guided Rewrite
Open
MetricsExecutionListenerTest.java
and adapt it in three ways:
- Change the metric prefix to
myapp.blogeand verify that the timer names follow the new prefix. - Add a fallback path and assert that the fallback counter increments exactly once.
- Register an inline listener that records event strings and compare its event order with the metrics emitted for the same execution.
The exercise is useful because it keeps the graph constant while you vary the observability lens.
Brain Check
-
What is the difference between an
ExecutionListenerand anOperatorInterceptor?(A listener reacts to lifecycle events after they are emitted; an interceptor wraps the operator invocation itself and can add behavior around it.)
-
Why must
TracingOperatorInterceptorbe registered in two places?(Its listener side manages the graph-level span; its interceptor side creates node-level spans.)
-
What problem does
CallerContextCarriersolve?(It propagates caller-thread context such as MDC or OpenTelemetry state into the engine's virtual threads.)
-
When should you choose
AuditJournalListenerover plain metrics?(When you need structured, per-event auditability instead of aggregate counters or timers.)
-
What guarantee does the engine provide if a listener throws?
(Listener failures are isolated; execution continues and other listeners still run.)
Lab
Goal: make one graph observable in three complementary ways.
- Build a small graph with one successful node and one flaky node.
- Register
MetricsExecutionListenerand verify a retry counter appears. - Register
TracingOperatorInterceptorin both places and verify graph and node spans are created. - Register
AuditJournalListenerwith an in-memory store and assert that the expectedNODE_START,NODE_RETRY, andNODE_COMPLETEevents were stored. - Add MDC propagation with
MdcContextCarrierand verify a node log line sees the caller's correlation ID.
Stretch: run the same graph once with observability enabled and once without it, then compare what each lens helps you answer fastest.
Experiment acceptance card
- Expected and observed: One executionId connects metric, trace, log, and audit.
- Failure and recovery: Remove correlation; restore the shared identity and rebuild the timeline.
- Proof boundary: Proves projections can correlate, not that metrics explain business cause.
- Exercise contract: One retry-to-fallback incident; change correlation only; deliver four projections; stop when all resolve to one execution.
Recap
- Listeners observe lifecycle events; interceptors wrap operator calls.
- Metrics, tracing, logging, and audit journaling all hang off the same engine extension surfaces.
CallerContextCarrieris the missing link between caller-thread context and virtual-thread execution.- The safest observability integrations are reusable because they add visibility without changing graph logic.
Next Step
In Chapter 20 — Spring and Production Wiring, you will turn runtime visibility into an application integration: conditional beans, configuration boundaries, and operator-facing endpoints.
Reference Links
ExecutionListener— aggregate listener interfaceOperatorInterceptor— AOP interception contractCallerContextCarrier— capture/attach/close contractMetricsExecutionListener— Micrometer listenerTracingOperatorInterceptor— graph and node span wiringLoggingExecutionListener— structured logging listenerAuditJournalListener— structured audit captureExecutionEventStore— event journal storage SPI with tenant-scoped queriesBlogeObservabilityAutoConfiguration— Spring Boot auto-configuration- Core Architecture — engine internals and SPI overview
Coding Agent: Open the versioned task guide.