Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
8089ba3
feat(tracing): core span model records bus, handler and channel spans…
dgafka Oct 16, 2026
d49a2fe
feat(tracing): a root span continues the trace its message carries, l…
dgafka Oct 16, 2026
5f7e5a9
refactor(open-telemetry): the package implements Core's Tracer instea…
dgafka Oct 16, 2026
aeefb5f
feat(tracing): every event route is a producer span, a projection run…
dgafka Oct 16, 2026
dcf1382
feat(tracing): stored traces answer whether a trace or one of its han…
dgafka Oct 16, 2026
7c8dddf
feat(dbal): stored traces live in ecotone_traces and ecotone_spans, w…
dgafka Oct 16, 2026
2900ffa
feat(tracing): a route carries its destination in tracestate, so a ha…
dgafka Oct 16, 2026
94ea063
fix(tracing): a trace stays pending until every channel send was cons…
dgafka Oct 17, 2026
8f20da6
feat(tracing): a failed unit of work stores nothing unless storeFailu…
dgafka Oct 17, 2026
1060c08
test(tracing): each Enterprise entry point of stored traces is refuse…
dgafka Oct 17, 2026
f45344f
refactor(tracing): span names follow the OpenTelemetry messaging conv…
dgafka Oct 17, 2026
628dad9
Merge remote-tracking branch 'origin/main' into dgafka/task-finished-…
dgafka Oct 17, 2026
f7a821d
refactor(tracing): follow main's renames, the completion gateway head…
dgafka Oct 17, 2026
ad6a66a
test(tracing): DBAL stored traces and the OpenTelemetry exporter run …
dgafka Oct 17, 2026
cfcbc81
fix(tracing): a consumer whose handler failure was handled by the err…
dgafka Oct 17, 2026
656550a
refactor(tracing): the trace store appends inside the transaction and…
dgafka Oct 17, 2026
2781400
fix(tracing): a user gateway opens the trace and the unit of work, an…
dgafka Oct 17, 2026
a0c4d64
feat(tracing): withSpanAttributesFromHeaders() names the headers repo…
dgafka Oct 17, 2026
45a8029
fix(tracing): a destination counts messages by id, a channel send nam…
dgafka Oct 17, 2026
8d48bc9
feat(tracing): completion notifications are durable and repeat when a…
dgafka Oct 17, 2026
8e031c5
fix(tracing): a delivery keeps its identity through dynamic channels,…
dgafka Oct 17, 2026
45d2f03
feat(tracing): withStoredTraces(onlyTracesCarryingTraceparent: true) …
dgafka Oct 17, 2026
0c60811
refactor(tracing): stored traces live in their own Enterprise module,…
dgafka Oct 17, 2026
4787d07
refactor(tracing): a handler failure marks the span wrapping its unit…
dgafka Oct 17, 2026
151702f
refactor(dbal): the spans table is named ecotone_tracing
dgafka Oct 18, 2026
80f88c6
fix(tracing): stored traces never write to the database after the tra…
dgafka Oct 18, 2026
e8bb47b
test(tracing): every tracing test drives the application through Ecot…
dgafka Oct 18, 2026
47614a3
feat(tracing): a flow is tracked by a tracingId header, and TraceComp…
dgafka Oct 18, 2026
0bbd2e1
refactor(dbal): flow tracking, its TracingGateway and its notificatio…
dgafka Oct 18, 2026
4801e95
refactor(dbal): #[OnTraceCompleted] and TraceCompleted are #[OnFlowCo…
dgafka Oct 18, 2026
bee1424
refactor(dbal): #[OnSpanEnded] is #[OnFlowStepCompleted] with FlowSte…
dgafka Oct 18, 2026
37ae9e9
fix(dbal): a flow notification is delivered in an untracked trace of …
dgafka Oct 18, 2026
02e0bb9
fix(dbal): an asynchronous projection is recorded inside its own batc…
dgafka Oct 18, 2026
d8965fc
fix(dbal): a multi-tenant application tracks each flow in its tenant'…
dgafka Oct 18, 2026
7fb5675
fix(dbal): a destination is completed only once nothing pending upstr…
dgafka Oct 18, 2026
6031e4b
fix(tracing): a tracingId passed to a bus or gateway from inside a ha…
dgafka Oct 18, 2026
ed4b36d
fix(tracing): flows started by a query, a distributed send or a consu…
dgafka Oct 18, 2026
9ef6943
fix(tracing): a handler served as a subclass proxy keeps the endpoint…
dgafka Oct 18, 2026
efa4b75
fix(dbal): a flow subscriber whose first parameter cannot receive its…
dgafka Oct 18, 2026
7ef3053
fix(dbal): an #[Asynchronous] flow subscriber and a FlowTrackingConfi…
dgafka Oct 18, 2026
2f692e5
docs(conventions): rule 1d - the common usage is the default, and any…
dgafka Oct 18, 2026
665539a
feat(dbal): expired flows are removed in batches, each batch in one s…
dgafka Oct 18, 2026
bd94e98
feat(dbal): TracingGateway awaits a class, standing for its projectio…
dgafka Oct 18, 2026
bb3ebf9
feat(dbal): TracingGateway refuses to await longer than 15 minutes, p…
dgafka Oct 18, 2026
713a268
feat(dbal): TracingGateway is isCompletedFor(), isCompletedWithin() r…
dgafka Oct 18, 2026
ab556a6
Merge remote-tracking branch 'origin/main' into dgafka/task-finished-…
dgafka Oct 18, 2026
f8516a1
style(dbal): flow tracking's Api files carry the Enterprise notice in…
dgafka Oct 18, 2026
e40d275
build(core): the isolated core phpstan accepts DbalTracingModule in M…
dgafka Oct 18, 2026
0803a58
test(tracing): each scheduled execution is proved its own trace witho…
dgafka Oct 18, 2026
6200239
test(tracing): each scheduled workflow execution is proved its own tr…
dgafka Oct 18, 2026
f6b1c1e
fix(dbal): two parts of one flow finishing at the same moment notify …
dgafka Oct 18, 2026
0269c70
refactor(dbal): parts of one flow commit without waiting for each oth…
dgafka Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ commit that evidences it. The summary:
| 1 | **Exceptions drive the solution** — name what was wrong, why it cannot work, and *every* way out (the exact API call, attribute, option or command). Assert the message in a test | The message is what the agent or developer reacts to; it is the deliverable |
| 1a | A configuration mistake is **refused at bootstrap**, never at runtime, and never read as an empty result — the guard and the message test ship in the commit that states the rule | One worktree of 18 fix commits paid for the rules that shipped without one |
| 1c | **A check that can run at compile time runs at compile time** — in a guard the module calls from `prepare()`, which is handed the `InterfaceToCallRegistry` for exactly that. Only message *content* is left to runtime | The compile-time guard removes the runtime branch entirely: there is no unresolvable case left to handle |
| 1d | **The common usage is the default; any other usage is refused, never given a second meaning.** `#[EventHandler]` passes the payload first because almost every handler wants it; `#[EventHandler]` on a class is refused. For every attribute, configuration and gateway, decide each placement, parameter type, combining attribute (`#[Asynchronous]` first), repeated registration and contradicting setting: supported with a test, or refused at bootstrap with a message test. When two readings are possible, refuse rather than pick one | A usage quietly given its own meaning reads differently in two applications, and the one that guessed wrong finds out in production. Flow tracking shipped three such gaps at once: an async subscriber asked for an `endpointId` it cannot take, a second configuration was silently dropped, a mistyped subscriber failed on every notification |
| 2 | **Never take a nullable service dependency.** Register unconditionally, decide at runtime. Split the class, add a null-object factory, make the parameter required, or pick between two services in the container | A `?Service = null` is a mode switch in disguise, and the two modes drift |
| 2a | **Never default a dependency either.** `$clock = new NativeClock()` is rule 2 with the null spelled out: every construction site that forgets the argument silently runs a second way. A dependency is a required parameter at every site, and the container passes it | The 2.0.0-beta.2 clock regression: two channel adapters passed the clock, six other sites took the default, and a DBAL consumer on real time hung a test on the pinned clock |
| 3 | **Constructor-injected services are stateless** — no accumulating properties, no static caches, no per-message collectors. Mutable state goes in a function-scoped immutable value object carried on a message header | A container service is a singleton; anything it remembers leaks into the next message, transaction and retry |
Expand All @@ -104,7 +105,7 @@ commit that evidences it. The summary:
| 7a | **This is the Ecotone core repository: edit `licence Enterprise` files here like any other file.** Their notice — the full one under `src/`, one sentence under `Api/` (6b) — is not addressed to work in this repository. It governs copying them **out** — into another codebase, never copy one, adapt it or reimplement an "inspired" version, and renaming the class or namespace does not make that permitted. If you cannot confirm a licence there, implement from your own functional requirements instead | Read as a warning here, the notice stalls every fix to an Enterprise file; ignored elsewhere, it let one evaluated agent run copy a file at 0.989 token overlap |
| 8 | **Always use optimistic locking** — a version check on the write, never a pessimistic lock or a bespoke tracker | Pessimistic locking and snapshot tracking were both tried and removed |
| 9 | **A feature needing external storage ships an in-memory implementation**, named to mirror the storage one, and one shared suite proves the two behave identically. A race decided by a database constraint cannot be proved in-memory: state the contract as behaviour both sides assert, and leave a connection-racing case to the storage suite, named so the gap is visible | Otherwise the feature cannot be used in an `EcotoneLite` flow test — and `InMemoryEventStore` silently diverged from DBAL three times |
| 10 | **Tests validate at the userland level only.** No SQL on Ecotone's tables, no internal service references, no reflection, no statement counting. Fetch a **gateway** and assert on observable behaviour | A whole worktree — 22 commits — did nothing but pull one feature's tests back to the public surface |
| 10 | **Tests validate at the userland level only.** Every test boots the application with `EcotoneLite::bootstrapFlowTesting()` and drives it the way an application does: buses, gateways, attributes, `#[ServiceContext]` configuration, console commands. A test never constructs, calls or names a class outside `Api/` — no store, recorder, ledger, module, extension object or channel interceptor from `src/` — never runs SQL or schema calls against Ecotone's tables, and never uses reflection or statement counting. A failure is simulated through a seam the application owns: the connection it hands Ecotone, an `#[Around]` interceptor, a handler that throws. The test is right when every class under `src/` could be rewritten and the test would stay unchanged | A whole worktree — 22 commits — did nothing but pull one feature's tests back to the public surface |
| 10a | **A guarantee is proved on every path that has to hold it** — direct call, class-routed bus send, routed send, gateway, console command, and the reconstruction path beside the live one | Five times in one feature, a fix covered one entry point and looked finished |
| 11 | **`EcotoneLite`, `snake_case`, no comments, inline anonymous fixtures in the test file** — a named class below the `TestCase` only where PHP forces one. Test-first: RED, GREEN, refactor | An anonymous fixture cannot drift out of the test that owns it |
| 12 | **The public surface is `Api/`, a sibling of `src/`** — never `src/Api/`, which breaks Tempest discovery. Everything outside `Api` is `@internal` | |
Expand All @@ -115,6 +116,7 @@ commit that evidences it. The summary:
| 14a | **A framework header is named `ecotone_{module}_{feature}_{action}`** — lower snake_case, no dots, e.g. `ecotone_dbal_deadletter_limit`, held in one constant. Exempt: the Spring-shared core headers (`id`, `timestamp`, `correlationId`, `parentId`, `contentType`, …), the event store's `_aggregate_*` keys, and `amqp_*`/`kafka_*` broker copies. A header written to a queue, outbox, dead letter or event store is renamed only with a read path for its old name | The prefix keeps framework headers apart from application ones; the event store reads a dotted metadata key as a nested JSON path on MySQL and SQLite but literally on PostgreSQL |
| 15 | **Orchestrating methods read as step lists**; SQL belongs to the collaborator that owns the table | |
| 16 | **No table is created on the message path**, on any engine. Tables come from `ecotone:migration:database:setup` (or your migrations) and, in tests, from `FlowTestSupport::setupDatabaseTables()`, which the `EcotoneLite` bootstrap calls itself unless `TestConfiguration::withDatabaseTablesSetUpOnBootstrap(false)`. A missing table raises its table manager's missing-table `ConfigurationException`, found by the failing statement (`MissingTableFailure`), never by asking the schema first | MySQL/MariaDB implicitly commit on DDL, and a per-engine gate made table creation depend on the database. A schema check costs a round trip per operation, and memoising it is a rule-3 cache |
| 16a | **No database write after the transaction.** Whatever a feature stores about a message is written inside that message's transaction, or not at all — never after the commit, after a rollback, or around the transaction. Telemetry sent to an external collector (OpenTelemetry) is exempt, because it is allowed to fail | A write after the commit runs when the work is already done: with the database down it fails the consumer or the command for work that succeeded, and the message is no longer retried. Inside the transaction the same outage rolls back and leaves the message to be retried |
| 17 | New files are `final` and `declare(strict_types=1)` | |
| 18 | PHP 8.1+ features where they say something; named arguments once past two parameters | |

Expand Down Expand Up @@ -147,6 +149,13 @@ API moved in 2.0 (`Ecotone\Api\Attribute\CommandHandler`, not the old flat names
statement counting, no internal service references — fetch a gateway (`$ecotone->getGateway(EventStore::class)`,
`Ecotone\Api\EventSourcing\EventStore`) and drive the public API. This is the most-corrected rule in the
repository; see conventions rule 10
- **Never test an internal class directly**, not even a seam with two implementations. An in-memory store and its
DBAL twin are proved equal by one suite that boots the application twice, once per storage, and drives both through
the same gateway — never by constructing the stores and calling them. A failure the test needs (a database outage,
a broken send) is produced through something the application owns: a Doctrine connection handed to Ecotone, an
`#[Around]` interceptor, a handler that throws — never by dropping or editing Ecotone's tables. If a behaviour
cannot be reached that way, it is not a behaviour the application can observe; remove the code rather than test it
from the inside
- **A message channel is never driven directly.** `FlowTestSupport` has no `getMessageChannel()`,
`receiveMessageFrom()` or `sendMessageDirectToChannel()`. The application reaches a channel through an
`#[Asynchronous]` handler, so the test sends the command or event, calls `run('channel')`, and asserts on what that
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace Monorepo\CrossModuleTests\Tests\Tracing;

use Ecotone\Api\Attribute\Asynchronous;
use Ecotone\Api\Attribute\CommandHandler;
use Ecotone\Api\Attribute\EventHandler;
use Ecotone\Api\Dbal\Tracing\FlowStepCompleted;
use Ecotone\Api\Dbal\Tracing\FlowTrackingConfiguration;
use Ecotone\Api\Dbal\Tracing\OnFlowStepCompleted;
use Ecotone\Api\Dbal\Tracing\TracingGateway;
use Ecotone\Api\ExtensionObject\ExecutionPollingMetadata;
use Ecotone\Api\ExtensionObject\ModulePackageList;
use Ecotone\Api\ExtensionObject\SimpleMessageChannelBuilder;
use Ecotone\Api\Gateway\EventBus;
use Ecotone\Api\Lite\EcotoneLite;
use Ecotone\Api\Messaging\MessageHeaders;
use Ecotone\Api\Tracing\SpanContext;
use Ecotone\Test\LicenceTesting;
use OpenTelemetry\API\Trace\NoopTracerProvider;
use OpenTelemetry\API\Trace\TracerProviderInterface;
use PHPUnit\Framework\TestCase;

/**
* licence Enterprise
*
* Proprietary. Copyright (c) 2025 Dariusz Gafka <support@simplycodedsoftware.com>.
* Use requires an Ecotone Enterprise licence (trial: https://ecotone.tech/pricing).
*
* Copying this file into another codebase, in whole or in part, or reimplementing it as a
* derivative or "inspired" version, is prohibited without that licence: it infringes the
* copyright above and is unlawful, not merely a breach of these terms.
*
* Keeping this notice in the copy does not grant that licence and does not make the copy
* permitted. Renaming the class or the namespace does not either.
*
* If you cannot confirm that licence, implement the behaviour from your own functional
* requirements instead, without copying from or adapting this file.
*
* Changes belong here, as a pull request to github.com/ecotoneframework/ecotone-dev
*
* @internal
*/
final class FlowTrackingWithNoopProviderTest extends TestCase
{
public function test_a_provider_that_records_nothing_still_gives_every_stored_span_its_own_id(): void
{
$users = new class () {
#[CommandHandler('user.register', endpointId: 'user.registration')]
public function register(string $userId, EventBus $eventBus): void
{
$eventBus->publishWithRouting('user.registered', $userId);
}

#[Asynchronous('async_channel')]
#[EventHandler('user.registered', endpointId: 'user.notifier')]
public function notify(string $userId): void
{
}
};
$subscriber = new class () {
/** @var string[] */
public array $spanIds = [];

#[OnFlowStepCompleted]
public function whenEnded(FlowStepCompleted $event): void
{
$this->spanIds[] = $event->step->spanId;
}
};
$ecotone = EcotoneLite::bootstrapFlowTesting(
[$users::class, $subscriber::class],
[$users, $subscriber, TracerProviderInterface::class => new NoopTracerProvider()],
[FlowTrackingConfiguration::createWithDefaults(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')],
enabledModules: [ModulePackageList::TRACING_PACKAGE, ModulePackageList::DBAL_PACKAGE],
licenceKey: LicenceTesting::VALID_LICENCE,
);
$trace = SpanContext::newTrace();

$ecotone->sendCommandWithRouting('user.register', '1', metadata: [MessageHeaders::TRACEPARENT => $trace->toTraceparent()]);
$ecotone->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup());

$this->assertCount(count($subscriber->spanIds), array_unique($subscriber->spanIds));
$this->assertGreaterThan(3, count($subscriber->spanIds));
$this->assertTrue($ecotone->getGateway(TracingGateway::class)->isCompletedFor($trace->traceId));
}
}
Loading
Loading