Skip to content

feat: vizualize the message flow graph, and prove tracing, OpenTelemetry, flow tracking and the graph against one catalogue of flows - #725

Merged
dgafka merged 87 commits into
mainfrom
dgafka/full-flow-dump
Oct 19, 2026
Merged

dgafka merged 87 commits into
mainfrom
dgafka/full-flow-dump

Conversation

@dgafka

@dgafka dgafka commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Why is this change proposed?

Why

Important

Some span names, statuses and attributes change for existing tracing and OpenTelemetry users. Dashboards or alerts keyed on the old values need updating — see Before / after.

An application can now dump its whole message flow as a graph (Enterprise), and every feature that shows a flow — core tracing, OpenTelemetry, flow tracking and the graph — is proved against one shared catalogue of flows. Before, each feature had its own tests over its own model, so they drifted: a flow could be traced correctly yet stored wrongly by flow tracking, or be missing from the graph, with no test noticing.

  • A catalogue scenario performs an action (send a command, run a consumer) and states the expected outcome once, as a tree of core tracing spans. Each feature adapts that expectation to its own model, so a scenario added once is checked by all four.
  • The catalogue holds 85 core scenarios and 10 cross-module ones (database channels, dead letter, outbox, event-store projections, multi-tenancy). Writing them surfaced the behaviour changes below.

Before / after

Situation Before After
Projection trigger, reset, backfill, rebuild, initialize, delete Untraced: replayed handler spans had no parent, and flow tracking stored nothing Each starts its own flow, e.g. rebuild orders with ecotone.projection.operation
Polling projection, or scheduled endpoint without a request channel receive projecting_manager_handler:<name> / receive nullChannel poll <endpointId>
Message replayed from the dead letter or forwarded by the outbox The relayed send was missing from the stored flow; a batch to a high-throughput channel started a new trace Stored as a step of the original flow, each forwarded message continuing its own trace
Send that succeeds on a send retry; route held back by the message collector Error span; ecotone.route.undelivered Ok span; ecotone.route.collected

Example

$graph = $messagingSystem->getMessageFlowGraph();
echo $graph->toMermaid();
bin/console ecotone:flow:dump --format=mermaid   # or --format=json

For an application whose OrderService::place() command handler publishes OrderWasPlaced, handled asynchronously by Notifications and by an order_list projection, next to an order.status query handler, the dump renders as:

flowchart LR
    subgraph n1 ["Projection: order_list"]
        n8["Event Handler<br/>OrderListProjection::whenOrderPlaced"]
    end
    n0[("Channel<br/>notifications")]
    n2["Command Handler<br/>OrderService::place"]
    n3>"PlaceOrder"]
    n4["Query Handler<br/>OrderService::status"]
    n5{{"order.status"}}
    n6["Event Handler<br/>Notifications::whenOrderPlaced"]
    n7(["OrderWasPlaced"])
    n3 --> n2
    n5 --> n4
    n7 --> n0
    n0 ==>|"async"| n6
    n7 --> n8
    n2 -.->|"emits (publish)"| n7
Loading

An asynchronous handler sits behind its channel, in the order the message travels. The emitted event is inferred from the handler's source ($eventBus->publish(...)), shown as a dotted edge.

Description of Changes

Message flow graph (Enterprise)

ecotone:flow:dump and getMessageFlowGraph() draw buses, handlers, channels, aggregates, sagas, projections, interceptors, splitters, transformers, orchestrators and distributed routes. Without a licence the console command is not registered, and getMessageFlowGraph() throws a LicensingException.

Tracing and flow tracking

  • Dynamic consistency boundary spans (Decision Models:, Conditional Append:) moved from OpenTelemetry into core tracing, so flow tracking records them too.
  • The tracing channel interceptor runs last, so a send span is recorded only once the message reached its channel. A route whose message is filtered out is marked undelivered, and flow tracking no longer waits for it.
  • Projection lifecycle operations run through endpoints (ProjectionOperations), so tracing and flow tracking wrap them like any other entry point. The console commands and FlowTestSupport::triggerProjection(), resetProjection(), initializeProjection() and deleteProjection() go through them.
  • The dead letter replay and the outbox forwarder store their relayed sends through a FlowTrackingScope seam: TraceRecordingInterceptor when flow tracking is on, a pass-through otherwise.

Start reading at packages/Ecotone/src/Test/Flow/FlowScenarioCatalogue.php, then one suite: packages/Ecotone/tests/Tracing/TracingFlowConformanceTest.php.

Known gaps, left out deliberately
  • A projection's own #[ProjectionInitialization], #[ProjectionDelete] and #[ProjectionReset] hooks run inside the operation span, without spans of their own.
  • Each forwarding or scheduled poll is stored as its own one-span flow.
  • An orchestrator gateway's steps are decided at runtime, so the graph cannot draw them; the graph check skips that one scenario.

Pull Request Contribution Terms

  • I have read and agree to the contribution terms outlined in CONTRIBUTING.

dgafka added 30 commits October 18, 2026 22:50
… or JSON

MessageFlowGraph lists every command, event, query, handler, aggregate, saga,
projection, business interface, router, scheduled endpoint and channel the
application declares, with the edges between them: routing, input and output
channels, asynchronous delivery, applied events, and gateway request channels.
What a handler emits is inferred from its source: recordThat, publish, send,
sendWithRouting and publishWithRouting calls, event-sourced return values,
gateway calls through typed collaborators, and the channel names a router
returns. Inferred edges are marked as such.

The graph is reachable as ConfiguredMessagingSystem::getMessageFlowGraph(),
FlowTestSupport::getMessageFlowGraph() and ecotone:flow:dump --format=mermaid|json.
A #[Distributed] handler's message is delivered by the distributed bus node,
a handler calling sendCommand, convertAndSendCommand, publishEvent,
convertAndPublishEvent or sendMessage on a DistributedBus collaborator is
linked to the remote message and the bus with the target service, and a
DistributedServiceMap wires the bus to its command and event channels.
Inferred nodes and edges are collected in keyed arrays and the graph is
created once, instead of re-creating and re-validating the whole graph per
edge. The source scanner tokenizes each file once for all handlers it holds
and slices method bodies by line index. The declared graph reaches the
container as one JSON string.

Measured on a generated application of 300 files and 1500 handlers (3602
nodes, 4200 edges), best of three runs: graph build 3462 ms -> 126 ms, of
which source scanning is 114 ms; the module adds about 30 ms to bootstrap.
The scanner keeps one filtering loop that also records the first token of
each line, and compares token ids against constant sets instead of calling
is() with a fresh array per token. Scanning 1500 handlers in 300 files:
114 ms -> 74 ms, against a 10 ms raw tokenizer floor.

The immutable-copy test now observes eventSubscriptions, which the flow
graph reads from DistributedServiceMap as a public readonly property.
dgafka added 21 commits October 19, 2026 18:15
…s, scheduled polls, logs as span events, message identity and trace carriers
… namespace scan earlier in the run cannot enable other packages
… its channel, and a route that delivered nothing leaves no work pending
… step leading to the next, and the event channels this service subscribes to
…, combined and dynamic channels, async gateways and orchestrator steps, distributed events, transformers, interceptors, the message collector, sagas and event-sourced projections
…tions, wildcard and interface subscriptions, and asynchronous aggregate actions
…pans of the core tracer, so every tracing feature records them
…dary, replacing the OpenTelemetry-only tests
…nothing queued or failed, so a replayed failure can complete it
…, dead letter replay and deduplication with every tracing feature in one run
…d and delete start their own traced and tracked flow, and a polling projection's consumer span is named after its poller
…poll of its endpoint, not as a receive from the null channel
…every message it carries, and the cross-module catalogue covers outbox forwarding to plain and high-throughput channels
…atabase event store: rebuilt, rebuilt through a database channel per partition, and deleted, initialized and backfilled
…nts consumed from their own databases, and the stored flow check reads each flow from its tenant
…ded from the outbox is stored as a step of its flow, and a relayed batch settles the delivery the message already had
…channels this service is allowed to publish to
@dgafka dgafka changed the title feat: dump the message flow graph, and prove tracing, OpenTelemetry, flow tracking and the graph against one catalogue of flows feat: vizualize the message flow graph, and prove tracing, OpenTelemetry, flow tracking and the graph against one catalogue of flows Oct 19, 2026
@dgafka
dgafka merged commit d0dd9fa into main Oct 19, 2026
14 checks passed
@dgafka
dgafka deleted the dgafka/full-flow-dump branch October 19, 2026 20:39
…acked marks of its own message, not of the batch's first flow
…, while one sent to the channel's error channel stays failed
… draws an asynchronous handler behind it, in the order the message travels
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant