Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3aff276
Send a durable request_id on signals and updates and expose it on fai…
rmcdaniel Oct 20, 2026
5c49bc0
Add optional worker execution tracing and scoped log correlation
rmcdaniel Oct 20, 2026
4ebb81e
Connect optional telemetry to Laravel and Symfony worker factories
rmcdaniel Oct 20, 2026
aa3c9cc
Bound the optional OpenTelemetry API to its supported major line
rmcdaniel Oct 20, 2026
326de19
Reproduce rejection of published Server Unicode command keys
rmcdaniel Oct 20, 2026
f4d40fa
Preserve the Server Unicode caller key limit without mbstring
rmcdaniel Oct 20, 2026
0de9809
Merge commit '81780d08dcb09e9590a1df8b41edc5d626b5db40' into feature/…
rmcdaniel Oct 20, 2026
e8dfe49
Reproduce fatal compatibility break in custom workflow handles
rmcdaniel Oct 20, 2026
1eaa814
Preserve compatibility for existing workflow handle implementors
rmcdaniel Oct 20, 2026
9deaa0f
Verify tracing contexts with fresh workflow factories
rmcdaniel Oct 20, 2026
2e2baff
Merge commit '1eaa814f250b380e447f339d1c17607744387754' into feature/…
rmcdaniel Oct 20, 2026
e97a4e0
Merge commit 'f33756c7b8cfcf9aa7359e425dc9a6688a3e4513' into feature/…
rmcdaniel Oct 20, 2026
76729ca
Merge commit 'ac69c379ce01505a7bdce68ac39853f5b30e38bd' into feature/…
rmcdaniel Oct 20, 2026
c07d1f0
Negotiate optional trace carriers after original task authority gates
rmcdaniel Oct 20, 2026
ec452b0
Separate trace validation and link observation responsibilities
rmcdaniel Oct 20, 2026
b4d0e3b
Merge remote-tracking branch 'origin/main' into feature/workflow773-o…
rmcdaniel Oct 20, 2026
ec03d20
Prepare optional tracing for the PHP 3.1 minor
rmcdaniel Oct 21, 2026
6d5affa
Align packaged onboarding identity with the PHP 3.1 minor
rmcdaniel Oct 21, 2026
67c0aa4
Merge remote-tracking branch 'origin/main' into feature/workflow773-o…
rmcdaniel Oct 21, 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
24 changes: 22 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ jobs:
composer require durable-workflow/sdk:@dev "${framework_packages[@]}" \
--no-interaction --prefer-dist --with-all-dependencies
fi
composer require --dev phpstan/phpstan:^2.0 --no-interaction --prefer-dist
composer require --dev phpstan/phpstan:^2.0 'monolog/monolog:^2.9|^3.0' --no-interaction --prefer-dist
- name: Install SDK into a fresh Laravel application for cached role isolation
if: matrix.framework == 'Laravel' && matrix.version == '13' && matrix.php == '8.4'
run: |
Expand Down Expand Up @@ -329,12 +329,32 @@ jobs:
bridge_path=src/Bridge/Symfony
compatibility_scripts=("tests/compat/${COMPAT_SCRIPT}")
fi
compatibility_scripts+=(tests/compat/history-cache.php)
compatibility_scripts+=(tests/compat/history-cache.php tests/compat/execution-telemetry.php)
"$consumer/vendor/bin/phpstan" analyse -c phpstan-framework.neon \
--autoload-file="$consumer/vendor/autoload.php" \
src/Bridge/FrameworkRuntimeException.php \
src/Bridge/ServiceConfiguration.php src/Bridge/Event \
"$bridge_path" "${compatibility_scripts[@]}"
- name: Exercise optional tracing in framework workers
if: >-
(matrix.framework == 'Laravel' && matrix.version == '9' && matrix.php == '8.1') ||
(matrix.framework == 'Laravel' && matrix.version == '13' && matrix.php == '8.4') ||
(matrix.framework == 'Symfony' && matrix.version == '6.4')
env:
COMPAT_SCRIPT: ${{ matrix.script }}
FRAMEWORK_VERSION: ${{ matrix.version }}
DURABLE_WORKFLOW_TEST_TRACING: '1'
run: |
consumer="$(cat "$RUNNER_TEMP/framework-consumer")"
monolog='monolog/monolog:^3.0'
if [ "$COMPAT_SCRIPT" = laravel.php ] && [ "$FRAMEWORK_VERSION" = 9 ]; then
monolog='monolog/monolog:^2.9'
fi
composer --working-dir="$consumer" require --dev open-telemetry/api:1.10.1 "$monolog" \
--no-interaction --prefer-dist --no-progress
DURABLE_WORKFLOW_CLIENT_TOKEN=client-secret \
DURABLE_WORKFLOW_WORKER_TOKEN=worker-secret \
php "tests/compat/${COMPAT_SCRIPT}" "$consumer/vendor/autoload.php"

laravel-transition-compat:
name: Laravel ${{ matrix.source }} to service mode
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [3.1.0] - 2026-10-21

### Added

- Optional execution spans and Monolog correlation for workflow/query tasks
and real activity attempts. Applications own providers, exporters, sampling
and bounded shutdown. Default installations do not require OpenTelemetry.
- Bounded W3C caller propagation when Server discovery advertises the optional
observability capability. Run origins parent tasks; real dispatch, command
and outcome contexts become links after existing authority checks. Baggage
defaults empty and is excluded from links.
- Fiber scope restoration and fresh child-provider configuration for
supervised callbacks. Replay does not emit activity attempts for recorded
results. Existing request IDs and business arguments remain unchanged.

## [3.0.0] - 2026-10-20

### Changed
Expand Down
9 changes: 8 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,23 @@
"psr/log": "^1.1|^2.0|^3.0"
},
"require-dev": {
"monolog/monolog": "^2.9|^3.0",
"open-telemetry/api": "1.10.1",
"open-telemetry/sdk": "^1.15",
"phpdocumentor/phpdocumentor": "^3.7",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^10.5|^11.0"
},
"suggest": {
"ext-pcntl": "Required by framework console workers for graceful SIGINT and SIGTERM handling.",
"laravel/framework": "Enables the auto-discovered Laravel 9 through 13 service-mode bridge.",
"open-telemetry/api": "Optional worker tracing with the application's provider; supports API 1.10.1 and later in the 1.x line.",
"symfony/framework-bundle": "Enables the Symfony 6.4, 7, and 8 service-mode bundle.",
"symfony/console": "Required with symfony/framework-bundle to run the Symfony 6.4, 7, and 8 worker command."
},
"conflict": {
"open-telemetry/api": "<1.10.1 || >=2.0"
},
"autoload": {
"psr-4": {
"DurableWorkflow\\": "src/"
Expand Down Expand Up @@ -82,7 +89,7 @@
}
},
"durable-workflow": {
"product-train": "3.0.0",
"product-train": "3.1.0",
"supported-server-versions": "2.5.0",
"worker-protocol-version": "1.20",
"control-plane-version": "2",
Expand Down
6 changes: 3 additions & 3 deletions docs/quickstart-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"schema_version": 2,
"package": {
"name": "durable-workflow/sdk",
"published_version": "3.0.0",
"composer_requirement": "3.0.0",
"onboarding_requirement": "^3.0"
"published_version": "3.1.0",
"composer_requirement": "3.1.0",
"onboarding_requirement": "^3.1"
},
"runtime_targets": {
"server": {
Expand Down
43 changes: 43 additions & 0 deletions docs/tracing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Optional worker execution context

This integration adds scoped worker spans and log correlation. It also supports the optional service trace-context contract when the connected Server explicitly advertises it. Servers without that capability use independent task traces. Use the configured Server/SDK tuple qualified in [Workflow #773](https://github.com/durable-workflow/workflow/issues/773). Installing OpenTelemetry alone does not enable cross-process propagation.

Install the optional API and your preferred OpenTelemetry SDK/exporter. API 1.10.1 is the minimum supported API within the 1.x line. Configure application providers, propagators, sampling, buffering, exporter timeouts and shutdown before constructing the worker integration. The SDK does not install a provider, force a sampling decision or flush a provider per task.

```php
use DurableWorkflow\Telemetry\ExecutionTelemetry;
use DurableWorkflow\Telemetry\LogContext;
use DurableWorkflow\Worker;

// $client, $provider and $logger are application-owned.
$logger->pushProcessor(new LogContext()); // Monolog 2 or 3
$worker = new Worker($client, 'orders', telemetry: new ExecutionTelemetry($provider));
```

Omit `telemetry` for the default execution path. `new ExecutionTelemetry(enabled: false)` also disables instrumentation. When the optional API is absent, the integration is a no-op. Applications can use `ExecutionTelemetry::current()` within a supported execution scope to obtain the current correlation fields without adding wrappers to individual HTTP or database calls.

For a Laravel console worker, explicitly bind `ExecutionTelemetry::class` in the application container after configuring OpenTelemetry. The bridge injects that binding into its worker factory. Add `DurableWorkflow\Bridge\Laravel\LogContext::class` to the relevant logging channel's `tap` list. For a Symfony console worker, define an `ExecutionTelemetry` service and register the `DurableWorkflow\Telemetry\LogContext` service with the `monolog.processor` tag. Both bridges leave instrumentation disabled when the service is absent. Handler constructors resolved at worker startup are outside a task scope; per-invocation handler resolution runs within the execution scope.

`durable.workflow.task`, `durable.activity.task` and `durable.query.task` span the claimed task. Actual callbacks produce `durable.activity.attempt` or `durable.local_activity.attempt` children. Replaying a completed activity does not create another attempt span. SDK-created workflow Fibers bind their hosting task context inside the Fiber and restore it on unwinding. Unrelated application Fibers receive no durable correlation fields automatically.

The instrumentation scope is `durable-workflow/sdk`, with the installed package version. Fields include `workflow_instance_id`, `workflow_run_id`, `workflow_type`, `namespace`, `task_id`, `task_queue`, `activity_id`, `activity_type`, `activity_attempt_id` and `activity_attempt_number` when available. `activity_id` identifies the logical Server activity execution across retries. Older local activity reports without a logical execution ID omit it. Trace and span IDs follow the currently active application child span. An application's log-record `request_id` is preserved.

Field values are bounded and control characters are rejected. Arguments, results, exception messages, stack arguments, credentials and HTTP bodies are excluded from span attributes. High-cardinality IDs are not installed as metric labels. Telemetry setup, status and exporter failures preserve the original callback result or exception.

## Optional service propagation

Configure the same integration on a client with `$client = $client->withTelemetry($telemetry)`. The returned client is a clone. Use the existing explicit `$client->clusterInfo()` discovery during application setup. Workers use their existing cluster discovery. The SDK caches only an exact supported `worker_protocol.server_capabilities.observability_context` object. Missing, malformed or unknown capabilities disable propagation. Business commands do not add a discovery request or retry because telemetry is unavailable.

When available, start, signal, update and cancellation requests capture the caller's context before HTTP instrumentation begins. Child-workflow and continue-as-new commands capture their actual workflow execution context. The SDK adds only the structural `observability_context` field and preserves application arguments and request keys.

Workers read optional metadata after the existing original claim and authority gates. A valid run origin becomes the task's parent. A task-bound dispatch context and validated command or outcome observations become links, without replacing the run parent. Child terminal events may omit their producer task ID. The SDK never fabricates one. Existing Servers continue to use independent task spans.

Official W3C propagators validate trace headers. Baggage is dropped unless its key appears in the `baggageAllowlist` constructor option, and links never carry baggage. Context and link envelopes are bounded to 4096 bytes, with at most 32 links. Invalid metadata is ignored; span diagnostics contain only `discarded` or `filtered`. The optional capability and published-tuple qualification determine Server propagation support.

## Cooperative callback processes

Cooperative activity callbacks run in a Unix child process. The owner retains the task span. By default, tracing in the child is disabled to avoid reusing inherited exporter connections. Durable log identities remain available when the API is installed.

To trace the child callback, supply `childProviderFactory` when constructing `ExecutionTelemetry`. The factory runs after fork and must create a fresh provider with a synchronous span processor and bounded exporter I/O. Child processes stop without running inherited PHP shutdown hooks; a batch processor that only drains at shutdown loses its buffered spans. A missing or failing factory uses a no-op child provider. Abrupt process termination can lose telemetry and does not provide exactly-once export.

The application owns the export queue limits, timeouts, retry policy and final shutdown of its long-running worker provider. Keep export within the worker's existing execution and lease budgets. Configure a synchronous child exporter with a finite request timeout before enabling child spans.
13 changes: 13 additions & 0 deletions scripts/ci/run-replay-regression-fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,11 @@ private static function executeLayout(
}
},
);
if ($workflowType === 'golden.telemetry-context') {
$worker = new Worker(new Client('https://replay.invalid', transport: $transport, codec: $codec),
'regression-corpus', workerId: 'regression-consumer', clock: static fn (): float => 0,
telemetry: new \DurableWorkflow\Telemetry\ExecutionTelemetry());
}
if ($workflowType === 'golden.attributed-stateful') {
$worker->register(ReplayRegressionAttributedStatefulWorkflow::class);
} elseif ($workflowType === '0') {
Expand Down Expand Up @@ -639,6 +644,14 @@ private static function assertExpected(string $identity, array $fixture, array $
private static function workflow(string $workflowType): callable
{
return match ($workflowType) {
'golden.telemetry-context' => static function (WorkflowContext $context): array {
$result = $context->activity('golden.greet', ['Ada']);
$fields = \DurableWorkflow\Telemetry\ExecutionTelemetry::current();

return ['correlated' => ($fields['workflow_instance_id'] ?? null) === $context->workflowId
&& ($fields['workflow_run_id'] ?? null) === $context->runId,
'namespace' => $fields['namespace'] ?? null, 'result' => $result];
},
'golden.entry-arguments-required' => static fn (WorkflowContext $context, int $amount, int $required): int => $amount + $required,
'golden.finally-disposal' => static function (WorkflowContext $context): void {
try {
Expand Down
2 changes: 2 additions & 0 deletions src/Bridge/Laravel/DurableWorkflowServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ public function register(): void
$logger,
$events,
ProcessCredentialResolver::workerCredentialRole(),
$app->bound(\DurableWorkflow\Telemetry\ExecutionTelemetry::class)
? $app->make(\DurableWorkflow\Telemetry\ExecutionTelemetry::class) : null,
);
});
}
Expand Down
19 changes: 19 additions & 0 deletions src/Bridge/Laravel/LogContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace DurableWorkflow\Bridge\Laravel;

use Illuminate\Log\Logger;

/** Laravel logging tap; configure once on each application channel that needs correlation. */
final class LogContext
{
public function __invoke(Logger $logger): void
{
$target = $logger->getLogger();
if (method_exists($target, 'pushProcessor')) {
$target->pushProcessor(new \DurableWorkflow\Telemetry\LogContext());
}
}
}
3 changes: 3 additions & 0 deletions src/Bridge/Laravel/WorkerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use DurableWorkflow\Bridge\Event\WorkerDiagnosticEvent;
use DurableWorkflow\Bridge\ServiceConfiguration;
use DurableWorkflow\Client;
use DurableWorkflow\Telemetry\ExecutionTelemetry;
use DurableWorkflow\Worker;
use DurableWorkflow\Worker\WorkflowFactory;
use Illuminate\Contracts\Container\Container;
Expand All @@ -24,6 +25,7 @@ public function __construct(
private readonly LoggerInterface $logger,
private readonly ?Dispatcher $events = null,
private readonly string $credentialRole = 'worker',
private readonly ?ExecutionTelemetry $telemetry = null,
) {
if (!in_array($this->credentialRole, ['worker', 'shared'], true)) {
throw new InvalidArgumentException('The Laravel worker credential role must be worker or shared.');
Expand Down Expand Up @@ -63,6 +65,7 @@ public function make(?string $taskQueue = null): Worker
},
stickyCacheCapacity: $this->configuration->stickyCacheCapacity,
stickyCacheTtlSeconds: $this->configuration->stickyCacheTtlSeconds,
telemetry: $this->telemetry,
);

$handlers = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public function load(array $configs, ContainerBuilder $container): void
new ServiceLocatorArgument(new TaggedIteratorArgument(self::HANDLER_TAG, needsIndexes: true)),
new Reference(LoggerInterface::class, ContainerBuilder::NULL_ON_INVALID_REFERENCE),
new Reference(EventDispatcherInterface::class, ContainerBuilder::NULL_ON_INVALID_REFERENCE),
new Reference(\DurableWorkflow\Telemetry\ExecutionTelemetry::class, ContainerBuilder::NULL_ON_INVALID_REFERENCE),
])
->setPublic(true);
$container->register(WorkerCommand::class, WorkerCommand::class)
Expand Down
3 changes: 3 additions & 0 deletions src/Bridge/Symfony/WorkerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use DurableWorkflow\Bridge\Event\WorkerDiagnosticEvent;
use DurableWorkflow\Bridge\ServiceConfiguration;
use DurableWorkflow\Client;
use DurableWorkflow\Telemetry\ExecutionTelemetry;
use DurableWorkflow\Worker;
use DurableWorkflow\Worker\WorkflowFactory;
use Psr\Log\LoggerInterface;
Expand All @@ -26,6 +27,7 @@ public function __construct(
private readonly ServiceProviderInterface|iterable $handlers,
private readonly ?LoggerInterface $logger = null,
private readonly ?EventDispatcherInterface $events = null,
private readonly ?ExecutionTelemetry $telemetry = null,
) {
}

Expand All @@ -41,6 +43,7 @@ public function make(?string $taskQueue = null): Worker
},
stickyCacheCapacity: $this->configuration->stickyCacheCapacity,
stickyCacheTtlSeconds: $this->configuration->stickyCacheTtlSeconds,
telemetry: $this->telemetry,
);

$handlers = [];
Expand Down
Loading
Loading