From 8fb4328a0d6dc07d205ae9f01839558306aac1bd Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 22:23:44 +0000 Subject: [PATCH 1/6] Capture optional trace context only for new PHP admissions --- .../Controllers/Api/WorkflowController.php | 21 +-- app/Providers/AppServiceProvider.php | 3 + app/Support/WorkflowCommandContextFactory.php | 18 +- app/Support/WorkflowObservability.php | 132 +++++++++++++++ .../WorkflowObservabilityAdmission.php | 90 ++++++++++ docs/observability-context.md | 13 ++ .../WorkflowObservabilityAdmissionTest.php | 155 ++++++++++++++++++ .../WorkflowObservabilityFingerprintTest.php | 54 ++++++ 8 files changed, 475 insertions(+), 11 deletions(-) create mode 100644 app/Support/WorkflowObservability.php create mode 100644 app/Support/WorkflowObservabilityAdmission.php create mode 100644 docs/observability-context.md create mode 100644 tests/Feature/WorkflowObservabilityAdmissionTest.php create mode 100644 tests/Feature/WorkflowObservabilityFingerprintTest.php diff --git a/app/Http/Controllers/Api/WorkflowController.php b/app/Http/Controllers/Api/WorkflowController.php index d2c323820..60b91f98f 100644 --- a/app/Http/Controllers/Api/WorkflowController.php +++ b/app/Http/Controllers/Api/WorkflowController.php @@ -18,6 +18,7 @@ use App\Support\SearchAttributeValueValidator; use App\Support\TaskQueueRoutingGate; use App\Support\WorkflowCommandContextFactory; +use App\Support\WorkflowObservability; use App\Support\WorkflowQueryTaskBroker; use App\Support\WorkflowRunDiagnostics; use App\Support\WorkflowStartService; @@ -288,7 +289,7 @@ public function start(Request $request): JsonResponse } try { - $start = $this->workflowStartService->start( + $start = WorkflowObservability::admission($request, $workflowId, 'start', fn () => $this->workflowStartService->start( $validated, $namespace, $this->commandContexts->make( @@ -301,7 +302,7 @@ public function start(Request $request): JsonResponse 'duplicate_policy' => $validated['duplicate_policy'] ?? null, ], static fn (mixed $value): bool => $value !== null), ), - ); + )); } catch (ExternalPayloadStorageUnavailable $exception) { return ControlPlaneProtocol::jsonForRequest( $request, @@ -556,7 +557,7 @@ public function signal(Request $request, string $workflowId, string $signalName) retainExternal: true, ); - $result = $this->workflowControlPlane->signal( + $result = WorkflowObservability::admission($request, $workflowId, 'signal', fn () => $this->workflowControlPlane->signal( $workflowId, $signalName, [ @@ -575,7 +576,7 @@ public function signal(Request $request, string $workflowId, string $signalName) ), 'strict_configured_type_validation' => true, ], - ); + )); return $this->resultMapper->signal( $workflowId, @@ -767,7 +768,7 @@ public function update(Request $request, string $workflowId, string $updateName) ], static fn (mixed $value): bool => $value !== null), ); - $externalResult = $this->externalUpdates->admit( + $externalResult = WorkflowObservability::admission($request, $workflowId, 'update', fn () => $this->externalUpdates->admit( namespace: (string) $namespace, workflowId: $workflowId, updateName: $updateName, @@ -775,7 +776,7 @@ public function update(Request $request, string $workflowId, string $updateName) commandContext: $commandContext, waitFor: $waitFor, waitTimeoutSeconds: $validated['wait_timeout_seconds'] ?? null, - ); + )); if ($externalResult !== null) { return $this->resultMapper->update( @@ -787,7 +788,7 @@ public function update(Request $request, string $workflowId, string $updateName) ); } - $result = $this->workflowControlPlane->update( + $result = WorkflowObservability::admission($request, $workflowId, 'update', fn () => $this->workflowControlPlane->update( $workflowId, $updateName, [ @@ -798,7 +799,7 @@ public function update(Request $request, string $workflowId, string $updateName) 'wait_timeout_seconds' => $validated['wait_timeout_seconds'] ?? null, 'strict_configured_type_validation' => true, ], - ); + )); return $this->resultMapper->update( workflowId: $workflowId, @@ -833,7 +834,7 @@ public function cancel(Request $request, string $workflowId): JsonResponse 'request_id' => ['nullable', 'string', 'max:255'], ]); - $result = $this->workflowControlPlane->cancel( + $result = WorkflowObservability::admission($request, $workflowId, 'cancel', fn () => $this->workflowControlPlane->cancel( $workflowId, [ 'namespace' => $namespace, @@ -850,7 +851,7 @@ public function cancel(Request $request, string $workflowId): JsonResponse ), 'strict_configured_type_validation' => true, ], - ); + )); return $this->resultMapper->cancel( $workflowId, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7b628941f..10306cfdb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -30,6 +30,7 @@ use App\Support\StoragePressure; use App\Support\ValidatedExternalWorkflowUpdateAdmission; use App\Support\WorkflowMemoRollingCompatibility; +use App\Support\WorkflowObservability; use App\Support\WorkflowPackageApiFloor; use App\Support\WorkflowTaskLeaseConfiguration; use Illuminate\Console\Events\CommandStarting; @@ -233,6 +234,8 @@ public function boot(): void } WorkflowTask::observe(WorkflowTaskObserver::class); + WorkflowRun::creating(WorkflowObservability::creatingRun(...)); + WorkflowCommand::creating(WorkflowObservability::creatingCommand(...)); WorkflowHistoryEvent::observe(WorkflowHistoryEventObserver::class); WorkflowUpdate::observe(WorkflowUpdateValidationObserver::class); WorkflowInstance::observe(NamespaceDurableStateObserver::class); diff --git a/app/Support/WorkflowCommandContextFactory.php b/app/Support/WorkflowCommandContextFactory.php index 1f2ef1f79..171d3765f 100644 --- a/app/Support/WorkflowCommandContextFactory.php +++ b/app/Support/WorkflowCommandContextFactory.php @@ -214,7 +214,7 @@ private function requestMetadata(Request $request, string $commandName): array $fingerprintPayload = [ 'method' => $request->method(), 'path' => $path, - 'payload' => $this->normalize($request->all()), + 'payload' => $this->normalize($this->businessRequestPayload($request, $commandName)), 'headers' => $headers, ]; @@ -242,6 +242,22 @@ private function requestMetadata(Request $request, string $commandName): array ], static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []); } + /** + * Exclude only the optional carrier at its admitted structural location. + * Same-named fields inside business input retain their ordinary identity. + * + * @return array + */ + private function businessRequestPayload(Request $request, string $commandName): array + { + $payload = $request->all(); + if (in_array($commandName, ['start', 'signal_with_start', 'signal', 'update', 'cancel'], true)) { + unset($payload['observability_context']); + } + + return $payload; + } + private function bodyRequestId(Request $request): ?string { $value = $request->input('request_id'); diff --git a/app/Support/WorkflowObservability.php b/app/Support/WorkflowObservability.php new file mode 100644 index 000000000..467d35924 --- /dev/null +++ b/app/Support/WorkflowObservability.php @@ -0,0 +1,132 @@ + */ + private static array $rootAdmissions = []; + + /** @var WeakMap>|null */ + private static ?WeakMap $fiberAdmissions = null; + + public static function enabled(): bool + { + try { + $api = InstalledVersions::isInstalled('open-telemetry/api') + ? InstalledVersions::getVersion('open-telemetry/api') : null; + + return config('server.observability_context.enabled', false) === true + && is_string($api) && version_compare($api, '1.10.1', '>=') && version_compare($api, '2.0', '<') + && class_exists(TraceCarrier::class) + && class_exists(TraceContextPropagator::class); + } catch (Throwable) { + return false; + } + } + + /** @return list */ + public static function baggageAllowlist(): array + { + try { + $value = config('server.observability_context.baggage_allowlist', []); + + return is_array($value) ? array_values(array_filter($value, 'is_string')) : []; + } catch (Throwable) { + return []; + } + } + + public static function normalize(mixed $value, bool $allowBaggage = true): ?array + { + if (! self::enabled()) { + return null; + } + + try { + $carrier = TraceCarrier::normalize($value, $allowBaggage ? self::baggageAllowlist() : [], $diagnostic); + if (in_array($diagnostic, ['discarded', 'filtered'], true) && Span::getCurrent()->isRecording()) { + Span::getCurrent()->setAttribute('durable.trace_context.disposition', $diagnostic); + } + + return $carrier; + } catch (Throwable) { + return null; + } + } + + /** + * Called after request authentication, namespace and operation validation. + * The scope stores the request without reading its optional carrier. Only + * a new admitted model's creating hook may extract that metadata. + */ + public static function admission(Request $request, ?string $workflowId, string $operation, callable $business): mixed + { + if (! self::enabled()) { + return $business(); + } + $scope = new WorkflowObservabilityAdmission($request, $workflowId, $operation); + $fiber = Fiber::getCurrent(); + if ($fiber === null) { + self::$rootAdmissions[] = $scope; + } else { + self::$fiberAdmissions ??= new WeakMap; + self::$fiberAdmissions[$fiber] = [...(self::$fiberAdmissions[$fiber] ?? []), $scope]; + } + + try { + return $business(); + } finally { + if ($fiber === null) { + array_pop(self::$rootAdmissions); + } else { + $stack = self::$fiberAdmissions[$fiber] ?? []; + array_pop($stack); + if ($stack === []) { + unset(self::$fiberAdmissions[$fiber]); + } else { + self::$fiberAdmissions[$fiber] = $stack; + } + } + } + } + + public static function creatingRun(WorkflowRun $run): void + { + try { + self::currentAdmission()?->attachRun($run); + } catch (Throwable) { + // Optional metadata cannot prevent the original business insert. + } + } + + public static function creatingCommand(WorkflowCommand $command): void + { + try { + self::currentAdmission()?->attachCommand($command); + } catch (Throwable) { + // No raw carrier, baggage, request body or exception is logged. + } + } + + private static function currentAdmission(): ?WorkflowObservabilityAdmission + { + $fiber = Fiber::getCurrent(); + $stack = $fiber === null ? self::$rootAdmissions : (self::$fiberAdmissions[$fiber] ?? []); + $scope = $stack === [] ? null : $stack[array_key_last($stack)]; + + return $scope?->ownerPid === getmypid() ? $scope : null; + } +} diff --git a/app/Support/WorkflowObservabilityAdmission.php b/app/Support/WorkflowObservabilityAdmission.php new file mode 100644 index 000000000..0bff62517 --- /dev/null +++ b/app/Support/WorkflowObservabilityAdmission.php @@ -0,0 +1,90 @@ +ownerPid = getmypid(); + } + + public function attachRun(WorkflowRun $run): void + { + if (! in_array($this->operation, ['start', 'signal_with_start'], true) + || $this->runAttached + || $run->namespace !== $this->namespace() + || ($this->workflowId !== null && $run->workflow_instance_id !== $this->workflowId) + || ! $this->hasCarrierColumn($run)) { + return; + } + + $run->setAttribute('observability_context', $this->carrier()); + $this->workflowId = $run->workflow_instance_id; + $this->runAttached = true; + } + + public function attachCommand(WorkflowCommand $command): void + { + $types = match ($this->operation) { + 'start' => ['start'], + 'signal_with_start' => ['start', 'signal'], + 'signal' => ['signal'], + 'update' => ['update'], + 'cancel' => ['cancel', 'request_cancellation'], + default => [], + }; + $type = $this->value($command->command_type); + if ($command->workflow_instance_id !== $this->workflowId + || $this->value($command->status) !== 'accepted' + || ! in_array($type, $types, true) + || ($type === 'start' && $this->value($command->outcome) !== 'started_new') + || ! is_string($command->workflow_run_id) + || ! $this->hasCarrierColumn($command)) { + return; + } + $run = NamespaceWorkflowScope::run($this->namespace(), $this->workflowId, $command->workflow_run_id); + if ($run === null) { + return; + } + + $command->setAttribute('observability_context', $this->carrier()); + } + + private function namespace(): string + { + $value = $this->request->attributes->get('namespace'); + + return is_string($value) ? $value : ''; + } + + private function carrier(): ?array + { + return WorkflowObservability::normalize($this->request->input('observability_context')); + } + + private function hasCarrierColumn(Model $model): bool + { + return WorkflowObservability::enabled() + && $model->getConnection()->getSchemaBuilder()->hasColumn($model->getTable(), 'observability_context'); + } + + private function value(mixed $value): mixed + { + return $value instanceof BackedEnum ? $value->value : $value; + } +} diff --git a/docs/observability-context.md b/docs/observability-context.md new file mode 100644 index 000000000..611b0af0a --- /dev/null +++ b/docs/observability-context.md @@ -0,0 +1,13 @@ +# Optional trace context preparation + +This is draft PHP Server work for [Workflow #773](https://github.com/durable-workflow/workflow/issues/773). It does not advertise a capability or claim published service-mode tracing support. The source profile uses the unpublished Core tracing branch; stable delivery must use a qualified published Core package and a separately qualified Server/SDK tuple. + +The first source slice persists an optional caller origin on a newly admitted run or command. Authentication, namespace and business admission checks precede extraction. Metadata is attached to the existing business insert, using Core's nullable carrier columns. Returning an existing run does not replace its origin. A rejected or retained operation gains no tracing row or replacement metadata. + +The carrier is `observability_context` with schema `durable-workflow.trace-context`, integer version `1`, and a `carrier` map. The official OpenTelemetry W3C propagator validates trace context. Limits are 256 bytes for traceparent, 512 for tracestate, 2,048 for baggage and 4,096 for the complete JSON envelope. Baggage uses an explicit allowlist; the default is empty. Applications own the optional OpenTelemetry API, with 1.10.1 as the minimum supported 1.x version. An absent API or disabled feature leaves the ordinary business path available. + +Request fingerprints exclude only the admitted top-level caller carrier on start, signal-with-start, signal, update and cancellation operations. Same-named properties inside business arguments remain ordinary compared data. Dispatch/link fields at unadopted input locations are not blanket exclusions. Carriers do not enter authoritative histories or business payloads. Invalid metadata may emit only the fixed `discarded` or `filtered` disposition on an existing recording span; no raw carrier, baggage value, request body or exception text is logged by this integration. + +Remaining implementation covers authorized workflow/activity/query poll delivery, optional task-bound `dispatch_observability_context`, bounded `observability_links`, worker child/continue transitions, actual capability negotiation and published-tuple acceptance. No missing producer, outcome or event identity will be synthesized. Lease/receipt generations and original deadlines remain business authority, independent of telemetry. + +Native catalogs, shared/frozen fixtures, Cloud adoption and native migration/cutover remain outside this PHP implementation. Its release follows Server #552's qualified Core 2.8 adoption and requires fresh combined-source checks. diff --git a/tests/Feature/WorkflowObservabilityAdmissionTest.php b/tests/Feature/WorkflowObservabilityAdmissionTest.php new file mode 100644 index 000000000..a75b24199 --- /dev/null +++ b/tests/Feature/WorkflowObservabilityAdmissionTest.php @@ -0,0 +1,155 @@ + true, + 'server.observability_context.baggage_allowlist' => [], + 'workflows.v2.types.workflows' => ['tests.interactive-command-workflow' => InteractiveCommandWorkflow::class], + ]); + if (! WorkflowObservability::enabled()) { + if (getenv('DW_TEST_OBSERVABILITY_CONTEXT_REQUIRED') === '1') { + $this->fail('This qualification requires the tracing Core source and official optional API.'); + } + $this->markTestSkipped('Requires the optional tracing Core/API dependency profile.'); + } + Queue::fake(); + WorkflowNamespace::query()->create(['name' => 'default', 'description' => 'Default', 'status' => 'active', 'retention_days' => 30]); + } + + public function test_new_admission_persists_bounded_origin_outside_business_history(): void + { + $start = $this->start('origin', $this->carrier()); + $run = WorkflowRun::query()->findOrFail($start->json('run_id')); + $expected = $this->carrier(); + unset($expected['carrier']['baggage']); + $this->assertSame($expected, $run->observability_context); + $this->assertSame($expected, WorkflowCommand::query()->sole()->observability_context); + $history = json_encode(WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->get()->toArray(), JSON_THROW_ON_ERROR); + foreach (['traceparent', 'private-value', 'observability_context'] as $excluded) { + $this->assertStringNotContainsString($excluded, $history); + } + } + + public function test_default_empty_and_explicit_allowlist_control_baggage(): void + { + config(['server.observability_context.baggage_allowlist' => ['approved']]); + $start = $this->start('allowlist', $this->carrier()); + $carrier = WorkflowRun::query()->findOrFail($start->json('run_id'))->observability_context; + $this->assertSame('approved=tenant-a', $carrier['carrier']['baggage']); + $this->assertStringNotContainsString('private-value', json_encode($carrier, JSON_THROW_ON_ERROR)); + } + + public function test_retained_or_conflicting_start_never_replaces_an_origin(): void + { + $start = $this->start('retained', $this->carrier()); + $run = WorkflowRun::query()->findOrFail($start->json('run_id')); + $original = $run->getRawOriginal(); + $different = $this->carrier('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + $this->withHeaders($this->headers())->postJson('/api/workflows', [ + 'workflow_id' => 'retained', 'workflow_type' => 'tests.interactive-command-workflow', + 'duplicate_policy' => 'use-existing', 'observability_context' => $different, + ])->assertOk()->assertJsonPath('run_id', $run->id); + $this->withHeaders($this->headers())->postJson('/api/workflows', [ + 'workflow_id' => 'retained', 'workflow_type' => 'tests.interactive-command-workflow', + 'observability_context' => $different, + ])->assertConflict(); + $this->assertSame($original['observability_context'], $run->fresh()->getRawOriginal('observability_context')); + $this->assertSame(1, WorkflowRun::query()->count()); + $this->assertSame(1, WorkflowTask::query()->count()); + $this->assertSame(1, WorkflowCommand::query()->whereNotNull('observability_context')->count()); + } + + public function test_bad_or_absent_metadata_does_not_borrow_the_active_http_parent(): void + { + $scope = TraceContextPropagator::getInstance()->extract([ + 'traceparent' => '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ])->activate(); + try { + foreach ([null, 'malformed', ['schema' => 'unknown'], $this->carrier(str_repeat('0', 32))] as $index => $value) { + $start = $this->start('bad-'.$index, $value); + $this->assertNull(WorkflowRun::query()->findOrFail($start->json('run_id'))->observability_context); + } + } finally { + $scope->detach(); + } + } + + public function test_admitted_signal_links_its_caller_without_replacing_the_run_parent(): void + { + $start = $this->start('signal', $this->carrier()); + $run = WorkflowRun::query()->findOrFail($start->json('run_id')); + $original = $run->observability_context; + $task = WorkflowTask::query()->where('workflow_run_id', $run->id)->sole(); + (new RunWorkflowTask($task->id))->handle(app(WorkflowExecutor::class)); + $caller = $this->carrier('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + $this->withHeaders($this->headers())->postJson('/api/workflows/signal/signal/advance', [ + 'input' => ['Ada'], 'request_id' => 'real-signal', 'observability_context' => $caller, + ])->assertAccepted(); + unset($caller['carrier']['baggage']); + $command = WorkflowCommand::query()->where('command_type', 'signal')->sole(); + $this->assertSame($caller, $command->observability_context); + $this->assertSame($original, $run->fresh()->observability_context); + } + + public function test_failed_authority_and_rejected_business_input_do_not_persist_carriers(): void + { + config(['server.auth.driver' => 'token', 'server.auth.token' => 'valid-token']); + $this->withHeaders($this->headers() + ['Authorization' => 'Bearer wrong-token']) + ->postJson('/api/workflows', ['workflow_id' => 'unauthorized', + 'workflow_type' => 'tests.interactive-command-workflow', 'observability_context' => $this->carrier()]) + ->assertUnauthorized(); + $this->assertSame(0, WorkflowRun::query()->count()); + $this->assertSame(0, WorkflowCommand::query()->count()); + config(['server.auth.driver' => 'none']); + $this->withHeaders($this->headers())->postJson('/api/workflows', [ + 'workflow_type' => 'tests.interactive-command-workflow', 'execution_timeout_seconds' => -1, + 'observability_context' => $this->carrier(), + ])->assertUnprocessable(); + $this->assertSame(0, WorkflowRun::query()->count()); + $this->assertSame(0, WorkflowCommand::query()->count()); + } + + private function start(string $id, mixed $carrier): TestResponse + { + return $this->withHeaders($this->headers())->postJson('/api/workflows', [ + 'workflow_id' => $id, 'workflow_type' => 'tests.interactive-command-workflow', + 'observability_context' => $carrier, + ])->assertCreated(); + } + + private function carrier(string $traceId = '4bf92f3577b34da6a3ce929d0e0e4736'): array + { + return ['schema' => 'durable-workflow.trace-context', 'version' => 1, 'carrier' => [ + 'traceparent' => '00-'.$traceId.'-00f067aa0ba902b7-01', 'tracestate' => 'dwtest=one', + 'baggage' => 'approved=tenant-a,private=private-value', + ]]; + } + + private function headers(): array + { + return ['X-Namespace' => 'default', 'X-Durable-Workflow-Control-Plane-Version' => '2']; + } +} diff --git a/tests/Feature/WorkflowObservabilityFingerprintTest.php b/tests/Feature/WorkflowObservabilityFingerprintTest.php new file mode 100644 index 000000000..a3b27b491 --- /dev/null +++ b/tests/Feature/WorkflowObservabilityFingerprintTest.php @@ -0,0 +1,54 @@ + [$name], [ + 'start', 'signal_with_start', 'signal', 'update', 'cancel', + ]); + } + + #[DataProvider('admittedCommands')] + public function test_optional_caller_carrier_does_not_change_business_request_identity(string $command): void + { + $business = ['input' => ['observability_context' => ['customer' => 'retained']], 'request_id' => 'original']; + $original = $this->fingerprint($business, $command); + + foreach ([null, 'invalid telemetry', ['schema' => 'unknown', 'carrier' => ['baggage' => 'private=value']]] as $carrier) { + $this->assertSame($original, $this->fingerprint($business + ['observability_context' => $carrier], $command)); + } + $changed = $business; + $changed['input']['observability_context']['customer'] = 'different business value'; + $this->assertNotSame($original, $this->fingerprint($changed, $command)); + $this->assertNotSame($original, $this->fingerprint([...$business, 'request_id' => 'another request'], $command)); + } + + public function test_exclusion_does_not_expand_to_other_fields_or_unadopted_operations(): void + { + $business = ['input' => ['observability_context' => 'business value']]; + $original = $this->fingerprint($business, 'start'); + foreach (['dispatch_observability_context', 'observability_links'] as $field) { + $this->assertNotSame($original, $this->fingerprint($business + [$field => 'different'], 'start')); + } + $this->assertNotSame($this->fingerprint($business, 'query'), + $this->fingerprint($business + ['observability_context' => 'not an adopted field'], 'query')); + } + + private function fingerprint(array $body, string $command): string + { + $request = Request::create('/api/workflows/real-workflow/'.$command, 'POST', $body); + $request->headers->set('X-Request-Id', 'original-header'); + $request->attributes->set('namespace', 'default'); + + return app(WorkflowCommandContextFactory::class)->make($request, 'real-workflow', $command) + ->attributes()['context']['request']['fingerprint']; + } +} From d23fc4e92bf46a97625f63c31de8a75209bec8e2 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 23:22:04 +0000 Subject: [PATCH 2/6] Deliver bounded trace context after original poll and completion fences --- .env.example | 4 + .../Api/ActivityTaskController.php | 6 +- app/Http/Controllers/Api/WorkerController.php | 15 +- app/Providers/AppServiceProvider.php | 3 + app/Support/DispatchObservability.php | 103 +++++ app/Support/WorkerProtocol.php | 1 + app/Support/WorkflowObservability.php | 93 +++- .../WorkflowObservabilityAdmission.php | 25 +- app/Support/WorkflowObservabilityPoll.php | 338 ++++++++++++++ .../WorkflowObservabilityTransitions.php | 109 +++++ app/Support/WorkflowQueryTaskBroker.php | 4 + app/Support/WorkflowTaskPoller.php | 4 + config/dw-contract.php | 17 + config/server.php | 12 + docs/observability-context.md | 16 +- docs/server-reference.md | 3 + .../WorkflowObservabilityAdmissionTest.php | 35 ++ .../Feature/WorkflowObservabilityPollTest.php | 411 ++++++++++++++++++ .../WorkflowObservabilityTransitionsTest.php | 98 +++++ 19 files changed, 1280 insertions(+), 17 deletions(-) create mode 100644 app/Support/DispatchObservability.php create mode 100644 app/Support/WorkflowObservabilityPoll.php create mode 100644 app/Support/WorkflowObservabilityTransitions.php create mode 100644 tests/Feature/WorkflowObservabilityPollTest.php create mode 100644 tests/Feature/WorkflowObservabilityTransitionsTest.php diff --git a/.env.example b/.env.example index 6498227de..2c2f7aeb7 100644 --- a/.env.example +++ b/.env.example @@ -85,3 +85,7 @@ DW_METRICS_WORKFLOW_TASK_FAILURE_TYPE_LIMIT=20 # with the latest published `:8.0` / `:7-alpine` tag (TD-S058). MYSQL_VERSION=8.0 REDIS_VERSION=7-alpine + +# Optional trace context: requires the application-owned OpenTelemetry API. +DW_OBSERVABILITY_CONTEXT_ENABLED=false +DW_OBSERVABILITY_BAGGAGE_ALLOWLIST='[]' diff --git a/app/Http/Controllers/Api/ActivityTaskController.php b/app/Http/Controllers/Api/ActivityTaskController.php index c1a9a89da..8f3dad127 100644 --- a/app/Http/Controllers/Api/ActivityTaskController.php +++ b/app/Http/Controllers/Api/ActivityTaskController.php @@ -23,6 +23,7 @@ use App\Support\WorkerProtocol; use App\Support\WorkerProtocolMutationRetrier; use App\Support\WorkerSessionRegistry; +use App\Support\WorkflowObservabilityPoll; use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use Illuminate\Http\JsonResponse; @@ -187,7 +188,7 @@ public function poll(Request $request): JsonResponse ); return WorkerProtocol::json([ - 'task' => $claim === null ? null : array_filter([ + 'task' => $claim === null ? null : WorkflowObservabilityPoll::filterDelivered(array_filter([ 'task_id' => $claim['task_id'], 'workflow_id' => $claim['workflow_instance_id'], 'run_id' => $claim['workflow_run_id'], @@ -216,7 +217,8 @@ public function poll(Request $request): JsonResponse : null, ), 'external_executor' => $externalExecutor, - ], static fn (mixed $v): bool => $v !== null), + ...WorkflowObservabilityPoll::forActivityClaim((string) $namespace, $claim), + ], static fn (mixed $v): bool => $v !== null)), 'poll_status' => is_string($poll['poll_status'] ?? null) ? $poll['poll_status'] : ($claim === null ? 'empty' : 'leased'), diff --git a/app/Http/Controllers/Api/WorkerController.php b/app/Http/Controllers/Api/WorkerController.php index b192dc0f6..b63c9c7e8 100644 --- a/app/Http/Controllers/Api/WorkerController.php +++ b/app/Http/Controllers/Api/WorkerController.php @@ -45,6 +45,9 @@ use App\Support\WorkerTerminalEventAttribution; use App\Support\WorkflowHistoryPageToken; use App\Support\WorkflowMetadataCapabilityPolicy; +use App\Support\WorkflowObservability; +use App\Support\WorkflowObservabilityPoll; +use App\Support\WorkflowObservabilityTransitions; use App\Support\WorkflowQueryTaskBroker; use App\Support\WorkflowReplayEligibility; use App\Support\WorkflowStreamCommandProcessor; @@ -1460,6 +1463,10 @@ public function pollWorkflowTasks(Request $request): JsonResponse $task['sticky_replay_mode'] = null; } + if (is_array($task)) { + $task = WorkflowObservabilityPoll::filterDelivered($task); + } + return WorkerProtocol::json([ 'task' => $task, 'poll_status' => is_string($poll['poll_status'] ?? null) @@ -1805,6 +1812,9 @@ private function mutateWorkflowTaskCommands(Request $request, string $taskId, bo } $input = $request->all(); + if (is_array($input['commands'] ?? null)) { + $input['commands'] = WorkflowObservabilityTransitions::businessCommands($input['commands']); + } $topLevelValidator = Validator::make($input, $topLevelRules); $validationErrors = $topLevelValidator->fails() ? $topLevelValidator->errors()->messages() @@ -2132,7 +2142,8 @@ function () use ( // or attribute a terminal event to a prefix receipt. $outcome['completed'] = false; } else { - $outcome = $bridge->complete($taskId, $commands); + $outcome = WorkflowObservability::completion($request, $claimedTask, + fn (): array => $bridge->complete($taskId, $commands)); } if (($outcome[$checkpoint ? 'checkpointed' : 'completed'] ?? false) === true) { app(WorkflowStreamCommandProcessor::class)->process($taskId, (string) $namespace, $streamCommands); @@ -4399,7 +4410,7 @@ public function pollQueryTasks(Request $request): JsonResponse } return WorkerProtocol::json([ - 'task' => $poll['task'], + 'task' => is_array($poll['task']) ? WorkflowObservabilityPoll::filterDelivered($poll['task']) : null, 'poll_status' => $poll['poll_status'], ]); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 10306cfdb..b083230e8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -19,6 +19,7 @@ use App\Observers\WorkflowHistoryEventObserver; use App\Observers\WorkflowTaskObserver; use App\Observers\WorkflowUpdateValidationObserver; +use App\Support\DispatchObservability; use App\Support\ExternalWorkflowUpdateAdmission; use App\Support\NamespaceExternalPayloadStorage; use App\Support\RemoteScheduleStarter; @@ -235,7 +236,9 @@ public function boot(): void WorkflowTask::observe(WorkflowTaskObserver::class); WorkflowRun::creating(WorkflowObservability::creatingRun(...)); + WorkflowRun::created(WorkflowObservability::createdRun(...)); WorkflowCommand::creating(WorkflowObservability::creatingCommand(...)); + DispatchObservability::install(); WorkflowHistoryEvent::observe(WorkflowHistoryEventObserver::class); WorkflowUpdate::observe(WorkflowUpdateValidationObserver::class); WorkflowInstance::observe(NamespaceDurableStateObserver::class); diff --git a/app/Support/DispatchObservability.php b/app/Support/DispatchObservability.php new file mode 100644 index 000000000..0a2d306ea --- /dev/null +++ b/app/Support/DispatchObservability.php @@ -0,0 +1,103 @@ +taskId) || ! property_exists($job, 'dispatchObservabilityContext')) { + return []; + } + $value = $job->dispatchObservabilityContext; + $context = TaskTraceCarrier::fromJob($value, $job->taskId); + + return $context === null ? [] : ['dispatch_observability_context' => array_replace($value, ['context' => $context])]; + } catch (Throwable) { + return []; + } + } + + /** Called only after the poller's original task/attempt/lease fences. */ + public static function forTask(WorkflowTask $task): ?array + { + if (! WorkflowObservability::enabled()) { + return null; + } + try { + $raw = $task->getRawOriginal('queue_delivery_reference'); + if (! is_string($raw) || strlen($raw) > 2048) { + return null; + } + $reference = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + $manager = app('queue'); + if ($manager::class !== QueueManager::class) { + return null; + } + $queue = $manager->connection($task->connection); + if (! $queue instanceof DatabaseQueue || ! is_array($reference) || ! self::matches($reference, $task, $queue)) { + return null; + } + $row = $queue->database->table($queue->table)->useWritePdo() + ->where('id', $reference['job_id'])->where('queue', $reference['queue'])->first(['payload']); + if ($row === null || ! is_string($row->payload) || strlen($row->payload) > 65536) { + return null; + } + $payload = json_decode($row->payload, true, flags: JSON_THROW_ON_ERROR); + $jobClass = match ($task->task_type) { + TaskType::Workflow => RunWorkflowTask::class, + TaskType::Activity => RunActivityTask::class, + TaskType::Timer => RunTimerTask::class, + }; + if (($payload['uuid'] ?? null) !== $reference['payload_uuid'] || ($payload['data']['commandName'] ?? null) !== $jobClass) { + return null; + } + // The command remains an opaque serialized string. No job is unserialized. + $value = $payload['dispatch_observability_context'] ?? null; + $context = TaskTraceCarrier::fromJob($value, $task->id); + + return $context === null ? null : array_replace($value, ['context' => $context]); + } catch (Throwable) { + return null; + } + } + + private static function matches(array $reference, WorkflowTask $task, DatabaseQueue $queue): bool + { + return ($reference['version'] ?? null) === 1 && ($reference['task_id'] ?? null) === $task->id + && ($reference['run_id'] ?? null) === $task->workflow_run_id && ($reference['namespace'] ?? null) === $task->namespace + && ($reference['connection'] ?? null) === $queue->getConnectionName() + && ($reference['queue'] ?? null) === $queue->getQueue($task->queue) + && ($reference['database'] ?? null) === $queue->database->getName() + && ($reference['table'] ?? null) === $queue->table + && is_string($reference['payload_uuid'] ?? null) && $reference['payload_uuid'] !== '' && strlen($reference['payload_uuid']) <= 128 + && is_string($reference['job_id'] ?? null) + && filter_var($reference['job_id'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) !== false + && preg_match('/^[1-9]\d{0,19}$/D', $reference['job_id']) === 1; + } +} diff --git a/app/Support/WorkerProtocol.php b/app/Support/WorkerProtocol.php index 871ef0fcf..e3499c0f3 100644 --- a/app/Support/WorkerProtocol.php +++ b/app/Support/WorkerProtocol.php @@ -446,6 +446,7 @@ public static function serverCapabilities(): array $workerSessionSupported = self::workerSessionsSupported(); return [ + ...WorkflowObservability::capabilities(), 'long_poll_timeout' => (int) config( 'server.polling.timeout', WorkerProtocolVersion::DEFAULT_LONG_POLL_TIMEOUT, diff --git a/app/Support/WorkflowObservability.php b/app/Support/WorkflowObservability.php index 467d35924..ac41629fe 100644 --- a/app/Support/WorkflowObservability.php +++ b/app/Support/WorkflowObservability.php @@ -11,6 +11,9 @@ use WeakMap; use Workflow\V2\Models\WorkflowCommand; use Workflow\V2\Models\WorkflowRun; +use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Support\TaskOutcomeCarrier; +use Workflow\V2\Support\TaskTraceCarrier; use Workflow\V2\Support\TraceCarrier; /** Optional metadata for an existing, authorized business operation. */ @@ -23,15 +26,24 @@ final class WorkflowObservability private static ?WeakMap $fiberAdmissions = null; public static function enabled(): bool + { + try { + return config('server.observability_context.enabled', false) === true + && self::runtimeAvailable(); + } catch (Throwable) { + return false; + } + } + + public static function runtimeAvailable(): bool { try { $api = InstalledVersions::isInstalled('open-telemetry/api') ? InstalledVersions::getVersion('open-telemetry/api') : null; - return config('server.observability_context.enabled', false) === true - && is_string($api) && version_compare($api, '1.10.1', '>=') && version_compare($api, '2.0', '<') - && class_exists(TraceCarrier::class) - && class_exists(TraceContextPropagator::class); + return is_string($api) && version_compare($api, '1.10.1', '>=') && version_compare($api, '2.0', '<') + && class_exists(TraceCarrier::class) && class_exists(TaskTraceCarrier::class) + && class_exists(TaskOutcomeCarrier::class) && class_exists(TraceContextPropagator::class); } catch (Throwable) { return false; } @@ -43,12 +55,46 @@ public static function baggageAllowlist(): array try { $value = config('server.observability_context.baggage_allowlist', []); - return is_array($value) ? array_values(array_filter($value, 'is_string')) : []; + return is_array($value) && array_is_list($value) ? array_values(array_filter($value, 'is_string')) : []; } catch (Throwable) { return []; } } + /** No protocol floor: discovery reflects this PHP implementation and its actual schema. */ + public static function capabilities(): array + { + if (! self::enabled()) { + return []; + } + try { + foreach ([new WorkflowRun, new WorkflowCommand, new WorkflowTask] as $model) { + if (! $model->getConnection()->getSchemaBuilder()->hasColumn($model->getTable(), 'observability_context')) { + return []; + } + } + + return ['observability_context' => [ + 'supported' => true, 'schema' => 'durable-workflow.trace-context', 'version' => 1, + 'dispatch_schema' => 'durable-workflow.queue-trace', 'links_schema' => 'durable-workflow.trace-links', + 'max_context_bytes' => 4096, 'max_links' => 32, 'max_links_bytes' => 4096, + ]]; + } catch (Throwable) { + return []; + } + } + + public static function diagnostic(string $disposition): void + { + try { + if (in_array($disposition, ['discarded', 'filtered'], true) && Span::getCurrent()->isRecording()) { + Span::getCurrent()->setAttribute('durable.trace_context.disposition', $disposition); + } + } catch (Throwable) { + // Diagnostic hooks cannot replace the business result. + } + } + public static function normalize(mixed $value, bool $allowBaggage = true): ?array { if (! self::enabled()) { @@ -57,9 +103,7 @@ public static function normalize(mixed $value, bool $allowBaggage = true): ?arra try { $carrier = TraceCarrier::normalize($value, $allowBaggage ? self::baggageAllowlist() : [], $diagnostic); - if (in_array($diagnostic, ['discarded', 'filtered'], true) && Span::getCurrent()->isRecording()) { - Span::getCurrent()->setAttribute('durable.trace_context.disposition', $diagnostic); - } + self::diagnostic($diagnostic ?? ''); return $carrier; } catch (Throwable) { @@ -78,6 +122,30 @@ public static function admission(Request $request, ?string $workflowId, string $ return $business(); } $scope = new WorkflowObservabilityAdmission($request, $workflowId, $operation); + + return self::inScope($scope, $business); + } + + public static function completion(Request $request, WorkflowTask $task, callable $business): mixed + { + if (! self::runtimeAvailable()) { + return $business(); + } + $scope = new WorkflowObservabilityAdmission($request, null, 'workflow_complete'); + $result = self::inScope($scope, $business); + try { + if (is_array($result) && ($result['completed'] ?? false) === true && $scope->createdRunIds() !== []) { + WorkflowObservabilityTransitions::apply($request, $task, $scope->createdRunIds()); + } + } catch (Throwable) { + // Only newly created run metadata is eligible; the business result survives. + } + + return $result; + } + + private static function inScope(WorkflowObservabilityAdmission $scope, callable $business): mixed + { $fiber = Fiber::getCurrent(); if ($fiber === null) { self::$rootAdmissions[] = $scope; @@ -121,6 +189,15 @@ public static function creatingCommand(WorkflowCommand $command): void } } + public static function createdRun(WorkflowRun $run): void + { + try { + self::currentAdmission()?->rememberCreatedRun($run); + } catch (Throwable) { + // Recording an optional observation grants no new row or authority. + } + } + private static function currentAdmission(): ?WorkflowObservabilityAdmission { $fiber = Fiber::getCurrent(); diff --git a/app/Support/WorkflowObservabilityAdmission.php b/app/Support/WorkflowObservabilityAdmission.php index 0bff62517..7ad625b6d 100644 --- a/app/Support/WorkflowObservabilityAdmission.php +++ b/app/Support/WorkflowObservabilityAdmission.php @@ -15,6 +15,8 @@ final class WorkflowObservabilityAdmission private bool $runAttached = false; + private array $createdRuns = []; + public function __construct( private readonly Request $request, private ?string $workflowId, @@ -33,7 +35,7 @@ public function attachRun(WorkflowRun $run): void return; } - $run->setAttribute('observability_context', $this->carrier()); + $this->attach($run, $this->carrier()); $this->workflowId = $run->workflow_instance_id; $this->runAttached = true; } @@ -62,7 +64,26 @@ public function attachCommand(WorkflowCommand $command): void return; } - $command->setAttribute('observability_context', $this->carrier()); + $this->attach($command, $this->carrier()); + } + + public function rememberCreatedRun(WorkflowRun $run): void + { + if ($this->operation === 'workflow_complete' && $run->namespace === $this->namespace() && is_string($run->id)) { + $this->createdRuns[$run->id] = true; + } + } + + public function createdRunIds(): array + { + return array_keys($this->createdRuns); + } + + private function attach(Model $model, ?array $carrier): void + { + $attributes = $model->getAttributes(); + $attributes['observability_context'] = $carrier === null ? null : json_encode($carrier, JSON_THROW_ON_ERROR); + $model->setRawAttributes($attributes); } private function namespace(): string diff --git a/app/Support/WorkflowObservabilityPoll.php b/app/Support/WorkflowObservabilityPoll.php new file mode 100644 index 000000000..045668511 --- /dev/null +++ b/app/Support/WorkflowObservabilityPoll.php @@ -0,0 +1,338 @@ +isFuture()) { + return $wire; + } + $origin = WorkflowObservability::normalize($origin); + if ($origin !== null) { + $wire['observability_context'] = $origin; + } + $context = TaskTraceCarrier::fromJob($dispatch, $taskId); + if ($context !== null) { + $wire['dispatch_observability_context'] = array_replace($dispatch, ['context' => $context]); + } elseif ($dispatch !== null) { + WorkflowObservability::diagnostic('discarded'); + } + $validatedLinks = self::normalizeLinks($links, $taskId, $wire['run_id'] ?? null, $wire['namespace'] ?? null); + if ($validatedLinks !== null) { + $wire['observability_links'] = $validatedLinks; + } elseif ($links !== null) { + WorkflowObservability::diagnostic('discarded'); + } + } catch (Throwable) { + // Retain the business response with only the observations already validated. + } + + return $wire; + } + + public static function forWorkflowClaim(WorkflowRun $run, array $wire): array + { + if (! WorkflowObservability::enabled()) { + return []; + } + try { + if (! self::identity($wire['task_id'] ?? null)) { + return []; + } + $task = NamespaceWorkflowScope::task((string) $run->namespace, $wire['task_id']); + + return $task !== null ? self::forClaim($run, $task, $wire, 'workflow') : []; + } catch (Throwable) { + return []; + } + } + + public static function forActivityClaim(string $namespace, array $claim): array + { + if (! WorkflowObservability::enabled()) { + return []; + } + try { + $taskId = $claim['task_id'] ?? null; + $instanceId = $claim['workflow_instance_id'] ?? null; + $runId = $claim['workflow_run_id'] ?? null; + if (! self::identity($taskId) || ! self::identity($instanceId) || ! self::identity($runId)) { + return []; + } + $task = NamespaceWorkflowScope::task($namespace, $taskId); + $run = NamespaceWorkflowScope::run($namespace, $instanceId, $runId); + + return $task !== null && $run !== null ? self::forClaim($run, $task, $claim, 'activity') : []; + } catch (Throwable) { + return []; + } + } + + public static function forQuery(WorkflowRun $run, array $original): array + { + if (! WorkflowObservability::enabled()) { + return []; + } + try { + if (! self::identity($run->id) || ! self::identity($run->namespace) || ! self::identity($run->workflow_instance_id) + || ($original['status'] ?? null) !== 'leased' || ($original['namespace'] ?? null) !== $run->namespace + || ($original['run_id'] ?? null) !== $run->id || ($original['workflow_id'] ?? null) !== $run->workflow_instance_id + || ! self::identity($original['query_task_id'] ?? null) || ! self::identity($original['lease_owner'] ?? null) + || ! is_int($original['attempt_count'] ?? null) || $original['attempt_count'] < 1 + || ! is_string($original['query_admitted_at'] ?? null) || Carbon::parse($original['query_admitted_at'])->getTimestamp() <= 0 + || ! is_string($original['query_deadline_at'] ?? null) || ! Carbon::parse($original['query_deadline_at'])->isFuture() + || ! is_string($original['lease_expires_at'] ?? null) || ! Carbon::parse($original['lease_expires_at'])->isFuture()) { + return []; + } + $fields = ['namespace' => $run->namespace]; + $origin = WorkflowObservability::normalize($run->getAttribute('observability_context')); + if ($origin !== null) { + $fields['observability_context'] = $origin; + } + + return $fields; + } catch (Throwable) { + return []; + } + } + + public static function forClaim(WorkflowRun $run, WorkflowTask $task, array $wire, string $kind): array + { + if (! WorkflowObservability::enabled()) { + return []; + } + try { + if (! self::matchesOriginalClaim($run, $task, $wire, $kind)) { + return []; + } + $fields = ['namespace' => $run->namespace]; + $origin = WorkflowObservability::normalize($run->getAttribute('observability_context')); + if ($origin !== null) { + $fields['observability_context'] = $origin; + } + $dispatch = DispatchObservability::forTask($task); + if ($dispatch !== null) { + $fields['dispatch_observability_context'] = $dispatch; + } + if ($kind === 'workflow') { + $links = self::links($run, $task); + if ($links['links'] !== []) { + $fields['observability_links'] = $links; + } + } + + return $fields; + } catch (Throwable) { + return []; + } + } + + private static function matchesOriginalClaim(WorkflowRun $run, WorkflowTask $task, array $wire, string $kind): bool + { + $shape = match ($kind) { + 'workflow' => ['run_id', 'workflow_id', 'workflow_task_attempt', 'task_queue', TaskType::Workflow], + 'activity' => ['workflow_run_id', 'workflow_instance_id', 'attempt_number', 'queue', TaskType::Activity], + default => null, + }; + if ($shape === null) { + return false; + } + [$runKey, $instanceKey, $attemptKey, $queueKey, $type] = $shape; + + return self::identity($task->id) && self::identity($run->id) && self::identity($run->namespace) + && ($wire['task_id'] ?? null) === $task->id && ($wire[$runKey] ?? null) === $run->id + && ($wire[$instanceKey] ?? null) === $run->workflow_instance_id + && $task->workflow_run_id === $run->id && $task->namespace === $run->namespace + && $task->task_type === $type && $task->status === TaskStatus::Leased + && is_int($wire[$attemptKey] ?? null) && $wire[$attemptKey] > 0 + && $wire[$attemptKey] === (int) $task->attempt_count + && self::identity($wire['lease_owner'] ?? null) && $wire['lease_owner'] === $task->lease_owner + && ($wire[$queueKey] ?? null) === $task->queue + && is_string($wire['lease_expires_at'] ?? null) + && Carbon::parse($wire['lease_expires_at'])->isFuture() + && $task->lease_expires_at !== null && $task->lease_expires_at->isFuture() + && Carbon::parse($wire['lease_expires_at'])->equalTo($task->lease_expires_at) + && ($kind !== 'activity' || self::matchesActivityAttempt($task, $wire)); + } + + private static function matchesActivityAttempt(WorkflowTask $task, array $wire): bool + { + if (! self::identity($wire['activity_attempt_id'] ?? null) || ! self::identity($wire['activity_execution_id'] ?? null)) { + return false; + } + $attempt = ActivityAttempt::query()->whereKey($wire['activity_attempt_id']) + ->where('workflow_task_id', $task->id)->where('activity_execution_id', $wire['activity_execution_id']) + ->where('lease_owner', $wire['lease_owner'])->where('attempt_number', $wire['attempt_number']) + ->where('lease_expires_at', '>', now())->first(); + + return $attempt !== null; + } + + private static function links(WorkflowRun $run, WorkflowTask $task): array + { + $envelope = ['schema' => 'durable-workflow.trace-links', 'version' => 1, + 'task_id' => $task->id, 'run_id' => $run->id, 'namespace' => $run->namespace, 'links' => []]; + $commandId = is_array($task->payload) ? ($task->payload['workflow_command_id'] ?? null) : null; + if (self::identity($commandId)) { + $command = WorkflowCommand::query()->whereKey($commandId) + ->where('workflow_run_id', $run->id)->where('workflow_instance_id', $run->workflow_instance_id) + ->whereNotNull('accepted_at')->whereNull('rejected_at')->first(); + if ($command !== null) { + self::append($envelope, ['kind' => 'command', 'command_id' => $command->id], + $command->getAttribute('observability_context')); + } + } + foreach (TaskOutcomeCarrier::forRun($run, $task) as $outcome) { + $entry = isset($outcome['child_run_id']) ? self::childOutcome($outcome, $run) : self::taskOutcome($outcome); + if ($entry !== null) { + self::append($envelope, $entry, $outcome['context'] ?? null); + } + } + + return $envelope; + } + + private static function taskOutcome(array $outcome): ?array + { + $entry = ['kind' => 'task_outcome', 'task_id' => $outcome['task_id'] ?? null, + 'event_id' => $outcome['event_id'] ?? null, 'sequence' => $outcome['sequence'] ?? null, + 'event_type' => $outcome['event_type'] ?? null]; + foreach (['activity_id' => 'activity_execution_id', 'activity_attempt_id' => 'activity_attempt_id', 'timer_id' => 'timer_id'] as $source => $target) { + if (array_key_exists($source, $outcome)) { + $entry[$target] = $outcome[$source]; + } + } + + return self::validEntry($entry) ? $entry : null; + } + + private static function childOutcome(array $outcome, WorkflowRun $run): ?array + { + if (($outcome['parent_run_id'] ?? null) !== $run->id || ($outcome['parent_namespace'] ?? null) !== $run->namespace + || ($outcome['child_namespace'] ?? null) !== $run->namespace) { + return null; + } + $entry = ['kind' => 'child_outcome', 'workflow_link_id' => $outcome['link_id'] ?? null, + 'child_run_id' => $outcome['child_run_id'] ?? null, 'child_task_id' => $outcome['task_id'] ?? null, + 'event_id' => $outcome['event_id'] ?? null, 'sequence' => $outcome['sequence'] ?? null, + 'event_type' => $outcome['event_type'] ?? null]; + + return self::validEntry($entry) ? $entry : null; + } + + private static function append(array &$envelope, array $entry, mixed $value): void + { + if (count($envelope['links']) >= 32) { + WorkflowObservability::diagnostic('filtered'); + + return; + } + if (! self::validEntry($entry)) { + WorkflowObservability::diagnostic('discarded'); + + return; + } + $context = WorkflowObservability::normalize($value, allowBaggage: false); + if ($context === null) { + return; + } + $next = $envelope; + $next['links'][] = $entry + ['context' => $context]; + if (strlen(json_encode($next, JSON_THROW_ON_ERROR)) <= 4096) { + $envelope = $next; + } else { + WorkflowObservability::diagnostic('filtered'); + } + } + + private static function validEntry(array $entry): bool + { + $shape = match ($entry['kind'] ?? null) { + 'command' => [['command_id'], []], + 'task_outcome' => [['task_id', 'event_id', 'sequence', 'event_type'], ['activity_execution_id', 'activity_attempt_id', 'timer_id']], + 'child_outcome' => [['workflow_link_id', 'child_run_id', 'event_id', 'sequence', 'event_type'], ['child_task_id']], + default => null, + }; + if ($shape === null) { + return false; + } + [$required, $optional] = $shape; + if (array_diff($required, array_keys($entry)) !== [] + || array_diff(array_keys($entry), [...$required, ...$optional, 'kind', 'context']) !== []) { + return false; + } + foreach ($entry as $key => $value) { + if ($key === 'context') { + continue; + } + if ($key === 'sequence') { + if (! is_int($value) || $value < 1) { + return false; + } + } elseif ($key !== 'child_task_id' || $value !== null) { + if (! self::identity($value)) { + return false; + } + } + } + + return true; + } + + private static function normalizeLinks(mixed $value, string $taskId, mixed $runId, mixed $namespace): ?array + { + if (! is_array($value) || count($value) !== 6 || ! self::identity($runId) || ! self::identity($namespace) + || ($value['schema'] ?? null) !== 'durable-workflow.trace-links' || ($value['version'] ?? null) !== 1 + || ($value['task_id'] ?? null) !== $taskId || ($value['run_id'] ?? null) !== $runId + || ($value['namespace'] ?? null) !== $namespace + || array_diff(array_keys($value), ['schema', 'version', 'task_id', 'run_id', 'namespace', 'links']) !== [] + || ! is_array($value['links'] ?? null) || ! array_is_list($value['links']) || count($value['links']) > 32 + || strlen(json_encode($value, JSON_THROW_ON_ERROR)) > 4096) { + return null; + } + foreach ($value['links'] as $index => $entry) { + if (! is_array($entry) || ! self::validEntry($entry) || ! is_array($entry['context']['carrier'] ?? null) + || array_diff(array_keys($entry['context']['carrier']), ['traceparent', 'tracestate']) !== []) { + return null; + } + $context = WorkflowObservability::normalize($entry['context'], allowBaggage: false); + if ($context === null) { + return null; + } + $value['links'][$index]['context'] = $context; + } + + return $value; + } + + private static function identity(mixed $value): bool + { + return is_string($value) && $value !== '' && strlen($value) <= 512 + && preg_match('//u', $value) === 1 && ! preg_match('/[\x00-\x1f\x7f]/', $value); + } +} diff --git a/app/Support/WorkflowObservabilityTransitions.php b/app/Support/WorkflowObservabilityTransitions.php new file mode 100644 index 000000000..c8224a14e --- /dev/null +++ b/app/Support/WorkflowObservabilityTransitions.php @@ -0,0 +1,109 @@ + $command) { + if (is_array($command) && in_array($command['type'] ?? null, ['start_child_workflow', 'continue_as_new'], true)) { + unset($commands[$index]['observability_context']); + } + } + + return $commands; + } + + public static function apply(Request $request, WorkflowTask $original, array $createdRunIds): void + { + if ($original->status !== TaskStatus::Leased || $original->lease_expires_at === null + || ! $original->lease_expires_at->isFuture() || ! WorkflowObservability::runtimeAvailable()) { + return; + } + try { + // Savepoints isolate optional writes from the already successful business operation. + $original->getConnection()->transaction(function () use ($request, $original, $createdRunIds): void { + $parent = WorkflowRun::query()->whereKey($original->workflow_run_id)->where('namespace', $original->namespace)->first(); + if ($parent === null) { + return; + } + $links = WorkflowLink::query()->where('parent_workflow_run_id', $parent->id) + ->where('parent_workflow_instance_id', $parent->workflow_instance_id) + ->whereIn('child_workflow_run_id', $createdRunIds) + ->whereIn('link_type', ['child_workflow', 'continue_as_new'])->orderBy('sequence')->get(); + $events = WorkflowHistoryEvent::query()->where('workflow_run_id', $parent->id) + ->where('workflow_task_id', $original->id) + ->whereIn('event_type', ['ChildWorkflowScheduled', 'WorkflowContinuedAsNew'])->get(); + $inherited = TraceCarrier::normalize($parent->getAttribute('observability_context'), WorkflowObservability::baggageAllowlist()); + $producers = WorkflowObservability::enabled() ? self::producers($request) : []; + + foreach (['child_workflow', 'continue_as_new'] as $kind) { + $selected = $links->where('link_type', $kind)->values(); + $contexts = $producers[$kind] ?? []; + foreach ($selected as $index => $link) { + $expectedEvent = $kind === 'child_workflow' ? 'ChildWorkflowScheduled' : 'WorkflowContinuedAsNew'; + $event = $events->first(static fn (WorkflowHistoryEvent $event): bool => $event->event_type->value === $expectedEvent + && ($event->payload['workflow_link_id'] ?? null) === $link->id); + if ($event === null) { + continue; + } + $context = count($contexts) === $selected->count() ? ($contexts[$index] ?? null) : null; + self::writeOrigin($link, $parent, $context ?? $inherited); + } + } + }); + } catch (Throwable) { + // Missing metadata or unavailable observation storage cannot replace completion. + } + } + + private static function producers(Request $request): array + { + $result = ['child_workflow' => [], 'continue_as_new' => []]; + $commands = $request->input('commands'); + if (! is_array($commands)) { + return $result; + } + foreach ($commands as $command) { + if (! is_array($command)) { + continue; + } + $kind = match ($command['type'] ?? null) { + 'start_child_workflow' => 'child_workflow', + 'continue_as_new' => 'continue_as_new', + default => null, + }; + if ($kind !== null) { + $result[$kind][] = WorkflowObservability::normalize($command['observability_context'] ?? null); + } + } + + return $result; + } + + private static function writeOrigin(WorkflowLink $link, WorkflowRun $parent, ?array $context): void + { + if ($context === null) { + return; + } + $child = WorkflowRun::query()->whereKey($link->child_workflow_run_id) + ->where('workflow_instance_id', $link->child_workflow_instance_id)->where('namespace', $parent->namespace)->first(); + if ($child === null || ! $child->getConnection()->getSchemaBuilder()->hasColumn($child->getTable(), 'observability_context')) { + return; + } + // A raw update touches only the optional column, not timestamps or application mutators. + $child->getConnection()->table($child->getTable())->where('id', $child->id)->where('namespace', $parent->namespace) + ->update(['observability_context' => json_encode($context, JSON_THROW_ON_ERROR)]); + } +} diff --git a/app/Support/WorkflowQueryTaskBroker.php b/app/Support/WorkflowQueryTaskBroker.php index 6825aaf71..a374270d4 100644 --- a/app/Support/WorkflowQueryTaskBroker.php +++ b/app/Support/WorkflowQueryTaskBroker.php @@ -2266,6 +2266,10 @@ private function queryTaskPayload(array $task): array $payload['command_context'] = $task['command_context']; } + if ($run instanceof WorkflowRun) { + $payload += WorkflowObservabilityPoll::forQuery($run, $task); + } + return $payload; } diff --git a/app/Support/WorkflowTaskPoller.php b/app/Support/WorkflowTaskPoller.php index f21fb6877..c8652b27f 100644 --- a/app/Support/WorkflowTaskPoller.php +++ b/app/Support/WorkflowTaskPoller.php @@ -2099,6 +2099,10 @@ private function taskPayload( $payload['history_events_encoding'] = $history['history_events_encoding']; } + if ($run instanceof WorkflowRun && WorkflowObservability::enabled()) { + $payload += WorkflowObservabilityPoll::forWorkflowClaim($run, $payload); + } + return $payload; } diff --git a/config/dw-contract.php b/config/dw-contract.php index 2e8abf2f3..c3353a9de 100644 --- a/config/dw-contract.php +++ b/config/dw-contract.php @@ -52,6 +52,17 @@ 'vars' => [ + 'DW_OBSERVABILITY_CONTEXT_ENABLED' => [ + 'description' => 'Enable optional bounded trace context persistence, delivery and capability discovery when its optional API and schema are installed.', + 'default' => 'false', + 'since' => '2.10.0', + ], + 'DW_OBSERVABILITY_BAGGAGE_ALLOWLIST' => [ + 'description' => 'JSON list of baggage keys permitted on run origins; default empty. Links never carry baggage.', + 'default' => '[]', + 'since' => '2.10.0', + ], + // --- Server identity / mode ------------------------------------ 'DW_MODE' => [ @@ -598,6 +609,12 @@ 'since' => '2.0.0', 'legacy' => 'WORKFLOW_SERVER_EXTERNAL_PAYLOAD_MAX_BYTES', ], + 'DW_EXTERNAL_PAYLOAD_COMPLETION_MAX_BYTES' => [ + 'description' => 'Distinct bytes allowed across immutable upload slots for one leased completion; unset uses DW_EXTERNAL_PAYLOAD_MAX_BYTES.', + 'default' => '(unset)', + 'since' => '2.3.5', + 'legacy' => 'WORKFLOW_SERVER_EXTERNAL_PAYLOAD_COMPLETION_MAX_BYTES', + ], 'DW_EXTERNAL_PAYLOAD_REQUEST_TIMEOUT' => [ 'description' => 'Client-facing request-timeout budget advertised for external-payload upload and fetch.', 'default' => '30', diff --git a/config/server.php b/config/server.php index 8b6be75e9..21d8e153d 100644 --- a/config/server.php +++ b/config/server.php @@ -72,6 +72,18 @@ 'task_dispatch_mode_override' => EnvAuditor::env('DW_TASK_DISPATCH_MODE', 'WORKFLOW_V2_TASK_DISPATCH_MODE', null), + 'observability_context' => [ + 'enabled' => filter_var( + EnvAuditor::env('DW_OBSERVABILITY_CONTEXT_ENABLED', null, false), + FILTER_VALIDATE_BOOLEAN, + FILTER_NULL_ON_FAILURE, + ) ?? false, + 'baggage_allowlist' => json_decode( + (string) EnvAuditor::env('DW_OBSERVABILITY_BAGGAGE_ALLOWLIST', null, '[]'), + true, + ) ?? [], + ], + /* |-------------------------------------------------------------------------- | Workflow Fleet Validation Mode diff --git a/docs/observability-context.md b/docs/observability-context.md index 611b0af0a..0586b4a44 100644 --- a/docs/observability-context.md +++ b/docs/observability-context.md @@ -1,13 +1,23 @@ # Optional trace context preparation -This is draft PHP Server work for [Workflow #773](https://github.com/durable-workflow/workflow/issues/773). It does not advertise a capability or claim published service-mode tracing support. The source profile uses the unpublished Core tracing branch; stable delivery must use a qualified published Core package and a separately qualified Server/SDK tuple. +This is draft PHP Server work for [Workflow #773](https://github.com/durable-workflow/workflow/issues/773). It does not claim published service-mode tracing support. The draft implementation advertises its optional capability only when explicitly enabled with the required API and actual nullable columns available. The source profile uses the unpublished Core tracing branch; stable delivery must use a qualified published Core package and a separately qualified Server/SDK tuple. -The first source slice persists an optional caller origin on a newly admitted run or command. Authentication, namespace and business admission checks precede extraction. Metadata is attached to the existing business insert, using Core's nullable carrier columns. Returning an existing run does not replace its origin. A rejected or retained operation gains no tracing row or replacement metadata. +The implementation persists an optional caller origin on a newly admitted run or command. Authentication, namespace and business admission checks precede extraction. Metadata is attached to the existing business insert, using Core's nullable carrier columns. Returning an existing run does not replace its origin. A rejected or retained operation gains no tracing row or replacement metadata. The carrier is `observability_context` with schema `durable-workflow.trace-context`, integer version `1`, and a `carrier` map. The official OpenTelemetry W3C propagator validates trace context. Limits are 256 bytes for traceparent, 512 for tracestate, 2,048 for baggage and 4,096 for the complete JSON envelope. Baggage uses an explicit allowlist; the default is empty. Applications own the optional OpenTelemetry API, with 1.10.1 as the minimum supported 1.x version. An absent API or disabled feature leaves the ordinary business path available. Request fingerprints exclude only the admitted top-level caller carrier on start, signal-with-start, signal, update and cancellation operations. Same-named properties inside business arguments remain ordinary compared data. Dispatch/link fields at unadopted input locations are not blanket exclusions. Carriers do not enter authoritative histories or business payloads. Invalid metadata may emit only the fixed `discarded` or `filtered` disposition on an existing recording span; no raw carrier, baggage value, request body or exception text is logged by this integration. -Remaining implementation covers authorized workflow/activity/query poll delivery, optional task-bound `dispatch_observability_context`, bounded `observability_links`, worker child/continue transitions, actual capability negotiation and published-tuple acceptance. No missing producer, outcome or event identity will be synthesized. Lease/receipt generations and original deadlines remain business authority, independent of telemetry. +Authorized workflow, activity and query polls deliver the run origin only after the existing original claim, routing and history fences. An actual database-queue job can supply the task-bound `dispatch_observability_context`; its configured connection, queue, row ID, payload UUID, task and run bindings must match, and its serialized command is never decoded. The default service poll mode has no such producer and omits it. `observability_links` includes only real admitted commands and validated committed Core outcomes. Child and continue-as-new commands may supply a producer for a newly created run after successful fenced completion; a disabled source or malformed producer inherits the previously recorded parent origin. Optional fields are removed only at their accepted structural paths before business comparison. Published-tuple acceptance remains required. No missing producer, outcome or event identity will be synthesized. Lease/receipt generations and original deadlines remain business authority, independent of telemetry. + +Set `DW_OBSERVABILITY_CONTEXT_ENABLED=true` only on an application image that installs `open-telemetry/api:^1.10.1` and the qualified Core tracing package. The ordinary Server manifest does not require this API. The API alone does not export spans; the application owns its SDK/exporter configuration. Set `DW_OBSERVABILITY_BAGGAGE_ALLOWLIST='["tenant.id"]'` to permit an explicit key; malformed or non-list configuration permits none. Config caching preserves both controls. Disabling delivery strips cached carriers without modifying business fields, original leases or stored histories. + +Discovery exposes `worker_protocol.server_capabilities.observability_context` only for the enabled PHP implementation with the required API and all three actual nullable carrier columns. Its exact value is: + +```json +{"supported":true,"schema":"durable-workflow.trace-context","version":1,"dispatch_schema":"durable-workflow.queue-trace","links_schema":"durable-workflow.trace-links","max_context_bytes":4096,"max_links":32,"max_links_bytes":4096} +``` + +This capability adds no mandatory worker protocol floor. Workers must treat a missing, malformed or unknown capability as unavailable and must not infer it from a version string. Bounded discovery failure never changes business retries or admission. A fixed `discarded` or `filtered` disposition may be attached to an existing recording span; no diagnostic span or raw metadata log is created. Native catalogs, shared/frozen fixtures, Cloud adoption and native migration/cutover remain outside this PHP implementation. Its release follows Server #552's qualified Core 2.8 adoption and requires fresh combined-source checks. diff --git a/docs/server-reference.md b/docs/server-reference.md index 91365dce5..e25ff57a2 100644 --- a/docs/server-reference.md +++ b/docs/server-reference.md @@ -1721,6 +1721,9 @@ every operator-facing variable the server honors. | `DW_*` name | Default | Description | | --- | --- | --- | | `DW_MODE` | `service` | Server mode: "service" (external workers poll) or "embedded" (local queue). | +| `DW_OBSERVABILITY_CONTEXT_ENABLED` | `false` | Enable optional trace context persistence, poll delivery and capability discovery when the application-owned API and nullable schema are installed. See [trace context preparation](observability-context.md). | +| `DW_OBSERVABILITY_BAGGAGE_ALLOWLIST` | `[]` | JSON list of baggage keys permitted on run origins. Dispatch and secondary links never carry baggage. | +| `DW_EXTERNAL_PAYLOAD_COMPLETION_MAX_BYTES` | (unset) | Distinct bytes permitted across immutable upload slots in one leased completion; defaults to `DW_EXTERNAL_PAYLOAD_MAX_BYTES`. | | `DW_SERVER_ID` | `gethostname()` | Unique identifier for this server instance. | | `DW_SERVER_KEY` | generated at container boot | Optional server-internal runtime key. | | `DW_DEFAULT_NAMESPACE` | `default` | Namespace used when a request omits the namespace header. | diff --git a/tests/Feature/WorkflowObservabilityAdmissionTest.php b/tests/Feature/WorkflowObservabilityAdmissionTest.php index a75b24199..5c464bd42 100644 --- a/tests/Feature/WorkflowObservabilityAdmissionTest.php +++ b/tests/Feature/WorkflowObservabilityAdmissionTest.php @@ -4,10 +4,12 @@ use App\Models\WorkflowNamespace; use App\Support\WorkflowObservability; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Illuminate\Testing\TestResponse; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\Fixtures\InteractiveCommandWorkflow; use Tests\TestCase; use Workflow\V2\Jobs\RunWorkflowTask; @@ -53,6 +55,39 @@ public function test_new_admission_persists_bounded_origin_outside_business_hist } } + public function test_discovery_exposes_the_exact_optional_capability_without_a_protocol_floor(): void + { + config(['server.worker_protocol.version' => '1.1']); + $this->getJson('/api/cluster/info')->assertOk() + ->assertJsonPath('worker_protocol.version', '1.1') + ->assertJsonPath('worker_protocol.server_capabilities.observability_context', [ + 'supported' => true, 'schema' => 'durable-workflow.trace-context', 'version' => 1, + 'dispatch_schema' => 'durable-workflow.queue-trace', 'links_schema' => 'durable-workflow.trace-links', + 'max_context_bytes' => 4096, 'max_links' => 32, 'max_links_bytes' => 4096, + ]); + config(['server.observability_context.enabled' => false]); + $this->getJson('/api/cluster/info')->assertOk() + ->assertJsonMissingPath('worker_protocol.server_capabilities.observability_context'); + } + + public static function carrierModels(): array + { + return ['runs' => [WorkflowRun::class], 'commands' => [WorkflowCommand::class], 'tasks' => [WorkflowTask::class]]; + } + + #[DataProvider('carrierModels')] + public function test_missing_actual_column_is_not_advertised_and_business_admission_survives(string $class): void + { + $model = new $class; + $model->getConnection()->getSchemaBuilder()->table($model->getTable(), static function (Blueprint $table): void { + $table->dropColumn('observability_context'); + }); + $this->getJson('/api/cluster/info')->assertOk() + ->assertJsonMissingPath('worker_protocol.server_capabilities.observability_context'); + $this->start('legacy-schema', $this->carrier())->assertCreated(); + $this->assertSame(1, WorkflowRun::query()->count()); + } + public function test_default_empty_and_explicit_allowlist_control_baggage(): void { config(['server.observability_context.baggage_allowlist' => ['approved']]); diff --git a/tests/Feature/WorkflowObservabilityPollTest.php b/tests/Feature/WorkflowObservabilityPollTest.php new file mode 100644 index 000000000..26319e6e4 --- /dev/null +++ b/tests/Feature/WorkflowObservabilityPollTest.php @@ -0,0 +1,411 @@ +name() !== 'test_real_database_queue_producer_is_a_bound_link_without_replacing_origin') { + return parent::createApplication(); + } + $savedEnv = $_ENV; + $savedServer = $_SERVER; + $_ENV['DW_MODE'] = $_SERVER['DW_MODE'] = 'service'; + $_ENV['DW_TASK_DISPATCH_MODE'] = $_SERVER['DW_TASK_DISPATCH_MODE'] = 'queue'; + + try { + return parent::createApplication(); + } finally { + $_ENV = $savedEnv; + $_SERVER = $savedServer; + } + } + + protected function setUp(): void + { + parent::setUp(); + config(['server.observability_context.enabled' => true, 'server.observability_context.baggage_allowlist' => []]); + if (! WorkflowObservability::enabled()) { + if (getenv('DW_TEST_OBSERVABILITY_CONTEXT_REQUIRED') === '1') { + $this->fail('This qualification requires the tracing Core source and optional API.'); + } + $this->markTestSkipped('Requires the optional tracing Core/API dependency profile.'); + } + $this->actualQueue = app('queue'); + Queue::fake(); + $this->createNamespace('default'); + $this->configureWorkflowTypes(['tests.interactive-command-workflow' => InteractiveCommandWorkflow::class]); + $this->registerWorker('trace-worker', 'default', supportedWorkflowTypes: ['tests.interactive-command-workflow']); + } + + public function test_real_authorized_workflow_poll_delivers_the_committed_origin(): void + { + $runId = $this->start(); + $wire = $this->poll(); + $this->assertSame($runId, $wire['run_id']); + $this->assertSame('default', $wire['namespace']); + $this->assertSame($this->carrier(), $wire['observability_context']); + $this->assertSame('trace-worker', $wire['lease_owner']); + $this->assertSame(1, $wire['workflow_task_attempt']); + $this->assertArrayNotHasKey('dispatch_observability_context', $wire); + $this->assertArrayNotHasKey('observability_links', $wire); + } + + public function test_current_signal_resume_links_the_real_admitted_command(): void + { + $runId = $this->start(); + $this->runReadyWorkflowTask($runId); + $caller = $this->carrier('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + $this->withHeaders($this->apiHeaders())->postJson('/api/workflows/trace-workflow/signal/advance', [ + 'input' => ['Ada'], 'request_id' => 'signal-link', 'observability_context' => $caller, + ])->assertAccepted(); + $command = WorkflowCommand::query()->where('command_type', 'signal')->sole(); + $wire = $this->poll(); + $this->assertSame($this->carrier(), $wire['observability_context']); + $this->assertSame(['schema' => 'durable-workflow.trace-links', 'version' => 1, + 'task_id' => $wire['task_id'], 'run_id' => $runId, 'namespace' => 'default', + 'links' => [['kind' => 'command', 'command_id' => $command->id, 'context' => $caller]], + ], $wire['observability_links']); + } + + public function test_real_database_queue_producer_is_a_bound_link_without_replacing_origin(): void + { + Queue::swap($this->actualQueue); + $this->assertSame('queue', config('workflows.v2.task_dispatch_mode')); + config(['workflows.tracing.enabled' => true]); + $exporter = new InMemoryExporter; + $provider = new TracerProvider([new SimpleSpanProcessor($exporter)]); + $configuration = Configurator::create()->withTracerProvider($provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); + try { + $runId = $this->start(); + $task = WorkflowTask::query()->where('workflow_run_id', $runId)->sole(); + $this->assertIsString($task->getRawOriginal('queue_delivery_reference'), json_encode([ + 'mode' => config('workflows.v2.task_dispatch_mode'), 'transaction_level' => DB::transactionLevel(), + 'queue_manager' => get_debug_type(app('queue')), 'jobs' => DB::table('jobs')->count(), + 'dispatched' => $task->getRawOriginal('last_dispatched_at'), + 'dispatch_failed' => $task->getRawOriginal('last_dispatch_error') !== null, + ], JSON_THROW_ON_ERROR)); + $job = DB::table('jobs')->sole(); + $payload = json_decode($job->payload, true, flags: JSON_THROW_ON_ERROR); + $this->assertIsString($payload['data']['command']); + $producer = $payload['dispatch_observability_context']; + $spans = array_values(array_filter($exporter->getSpans(), static fn ($span): bool => $span->getName() === 'durable.task.dispatch')); + $this->assertCount(1, $spans); + $span = $spans[0]->getContext(); + $this->assertSame('00-'.$span->getTraceId().'-'.$span->getSpanId().'-01', $producer['context']['carrier']['traceparent']); + $this->assertArrayNotHasKey('baggage', $producer['context']['carrier']); + $wire = $this->poll(); + $this->assertSame($this->carrier(), $wire['observability_context']); + $this->assertSame($producer, $wire['dispatch_observability_context']); + $this->assertSame($wire['task_id'], $producer['task_id']); + $before = $task->fresh()->getRawOriginal(); + $reference = $task->getAttribute('queue_delivery_reference'); + $reference['payload_uuid'] = 'wrong-row-identity'; + $changed = $task->fresh(); + $changed->setRawAttributes(array_replace($changed->getRawOriginal(), [ + 'queue_delivery_reference' => json_encode($reference, JSON_THROW_ON_ERROR), + ]), true); + $this->assertNull(DispatchObservability::forTask($changed)); + $this->assertSame($before, $task->fresh()->getRawOriginal()); + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + public function test_real_activity_poll_delivers_its_run_origin_and_original_attempt(): void + { + $runId = $this->start(); + $workflow = $this->poll(); + $this->withHeaders($this->workerHeaders())->postJson('/api/worker/workflow-tasks/'.$workflow['task_id'].'/complete', [ + 'lease_owner' => 'trace-worker', 'workflow_task_attempt' => $workflow['workflow_task_attempt'], + 'commands' => [['type' => 'schedule_activity', 'activity_type' => 'tests.external-greeting-activity']], + ])->assertOk()->assertJsonPath('recorded', true); + $wire = $this->withHeaders($this->workerHeaders())->postJson('/api/worker/activity-tasks/poll', [ + 'worker_id' => 'trace-worker', 'task_queue' => 'default', 'timeout_seconds' => 0, + ])->assertOk()->json('task'); + $this->assertIsArray($wire); + $this->assertSame($runId, $wire['run_id']); + $this->assertSame('default', $wire['namespace']); + $this->assertSame($this->carrier(), $wire['observability_context']); + $this->assertSame(1, $wire['attempt_number']); + $this->assertNotEmpty($wire['activity_attempt_id']); + } + + public function test_committed_activity_outcome_links_its_real_producer_and_event(): void + { + config(['workflows.tracing.enabled' => true, + 'workflows.v2.types.activities' => ['tests.external-greeting-activity' => ExternalGreetingActivity::class]]); + $exporter = new InMemoryExporter; + $provider = new TracerProvider([new SimpleSpanProcessor($exporter)]); + $configuration = Configurator::create()->withTracerProvider($provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); + try { + $runId = $this->start(); + $first = $this->poll(); + $this->withHeaders($this->workerHeaders())->postJson('/api/worker/workflow-tasks/'.$first['task_id'].'/complete', [ + 'lease_owner' => 'trace-worker', 'workflow_task_attempt' => $first['workflow_task_attempt'], + 'commands' => [['type' => 'schedule_activity', 'activity_type' => 'tests.external-greeting-activity', + 'arguments' => ['codec' => 'avro', 'blob' => Serializer::serializeWithCodec('avro', ['Ada'])]]], + ])->assertOk()->assertJsonPath('recorded', true); + $producer = WorkflowTask::query()->where('workflow_run_id', $runId)->where('task_type', 'activity')->sole(); + (new RunActivityTask($producer->id))->handle(); + $event = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->where('event_type', 'ActivityCompleted')->sole(); + $wire = $this->poll(); + $entry = $wire['observability_links']['links'][0]; + $this->assertSame('task_outcome', $entry['kind']); + $this->assertSame($producer->id, $entry['task_id']); + $this->assertSame($event->id, $entry['event_id']); + $this->assertSame((int) $event->sequence, $entry['sequence']); + $this->assertSame('ActivityCompleted', $entry['event_type']); + $this->assertSame($event->payload['activity_execution_id'], $entry['activity_execution_id']); + $this->assertSame($event->payload['activity_attempt_id'], $entry['activity_attempt_id']); + $this->assertArrayNotHasKey('activity_id', $entry); + $this->assertArrayNotHasKey('baggage', $entry['context']['carrier']); + $spans = array_values(array_filter($exporter->getSpans(), static fn ($span): bool => $span->getName() === 'durable.activity.attempt')); + $this->assertCount(1, $spans); + $span = $spans[0]->getContext(); + $this->assertSame('00-'.$span->getTraceId().'-'.$span->getSpanId().'-01', $entry['context']['carrier']['traceparent']); + $this->assertSame($this->carrier(), $wire['observability_context']); + $this->assertStringNotContainsString('traceparent', json_encode($event->payload, JSON_THROW_ON_ERROR)); + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + public function test_real_query_poll_delivers_origin_without_changing_the_original_deadline(): void + { + $runId = $this->start(); + $this->runReadyWorkflowTask($runId); + $this->registerWorker('trace-query-worker', 'default', supportedWorkflowTypes: ['tests.interactive-command-workflow'], capabilities: ['query_tasks']); + $run = WorkflowRun::query()->findOrFail($runId); + $broker = app(WorkflowQueryTaskBroker::class); + $query = $broker->enqueue('default', $run, 'events-starting-with', [ + 'codec' => 'avro', 'blob' => Serializer::serializeWithCodec('avro', ['approved']), + ]); + $response = $this->withHeaders($this->workerHeaders())->postJson('/api/worker/query-tasks/poll', [ + 'worker_id' => 'trace-query-worker', 'task_queue' => 'default', 'timeout_seconds' => 0, + ])->assertOk(); + $wire = $response->json('task'); + $this->assertNull($wire, 'A worker without the recorded definition fingerprint must remain refused.'); + $definition = WorkflowDefinition::fingerprint(InteractiveCommandWorkflow::class); + $this->assertIsString($definition); + $this->assertSame($definition, $query['workflow_definition_fingerprint']); + WorkerRegistration::query()->where('worker_id', 'trace-query-worker')->sole()->forceFill([ + 'workflow_definition_fingerprints' => ['tests.interactive-command-workflow' => $definition], + ])->save(); + $response = $this->withHeaders($this->workerHeaders())->postJson('/api/worker/query-tasks/poll', [ + 'worker_id' => 'trace-query-worker', 'task_queue' => 'default', 'timeout_seconds' => 0, + ])->assertOk(); + $wire = $response->json('task'); + $this->assertIsArray($wire, json_encode(['poll_status' => $response->json('poll_status'), 'reason' => $response->json('reason'), + 'query_status' => $broker->task($query['query_task_id'])['status'] ?? null, + 'query_build' => $query['compatibility'] ?? null, + 'query_definition' => $query['workflow_definition_fingerprint'] ?? null, + 'query_queue' => $query['task_queue'] ?? null], JSON_THROW_ON_ERROR)); + $this->assertSame($query['query_task_id'], $wire['query_task_id']); + $this->assertSame($this->carrier(), $wire['observability_context']); + $this->assertSame('default', $wire['namespace']); + $this->assertSame(1, $wire['query_task_attempt']); + $this->assertSame($query['query_admitted_at'], $wire['query_admitted_at']); + $this->assertSame($query['query_deadline_at'], $wire['query_deadline_at']); + $stored = $broker->task($query['query_task_id']); + $this->assertSame($query['query_admitted_at'], $stored['query_admitted_at']); + $this->assertSame($query['query_deadline_at'], $stored['query_deadline_at']); + } + + public function test_child_outcome_uses_the_linked_childs_real_terminal_event_and_task(): void + { + config(['workflows.tracing.enabled' => true]); + $this->configureWorkflowTypes(['tests.interactive-command-workflow' => InteractiveCommandWorkflow::class, + 'tests.internal-child-workflow' => InternalChildWorkflow::class]); + $exporter = new InMemoryExporter; + $provider = new TracerProvider([new SimpleSpanProcessor($exporter)]); + $configuration = Configurator::create()->withTracerProvider($provider) + ->withPropagator(TraceContextPropagator::getInstance())->activate(); + try { + $runId = $this->start(); + $first = $this->poll(); + $this->withHeaders($this->workerHeaders())->postJson('/api/worker/workflow-tasks/'.$first['task_id'].'/complete', [ + 'lease_owner' => 'trace-worker', 'workflow_task_attempt' => $first['workflow_task_attempt'], + 'commands' => [['type' => 'start_child_workflow', 'workflow_type' => 'tests.internal-child-workflow', + 'arguments' => ['codec' => 'avro', 'blob' => Serializer::serializeWithCodec('avro', ['Ada'])]]], + ])->assertOk()->assertJsonPath('recorded', true); + $link = WorkflowLink::query()->where('parent_workflow_run_id', $runId)->sole(); + $this->runReadyWorkflowTask($link->child_workflow_run_id); + $terminal = WorkflowHistoryEvent::query()->where('workflow_run_id', $link->child_workflow_run_id) + ->where('event_type', 'WorkflowCompleted')->sole(); + $wire = $this->poll(); + $entry = $wire['observability_links']['links'][0]; + $this->assertSame('child_outcome', $entry['kind']); + $this->assertSame($link->id, $entry['workflow_link_id']); + $this->assertSame($link->child_workflow_run_id, $entry['child_run_id']); + $this->assertSame($terminal->workflow_task_id, $entry['child_task_id']); + $this->assertSame($terminal->id, $entry['event_id']); + $this->assertSame((int) $terminal->sequence, $entry['sequence']); + $this->assertSame('WorkflowCompleted', $entry['event_type']); + $spans = array_values(array_filter($exporter->getSpans(), static fn ($span): bool => $span->getName() === 'durable.workflow.task')); + $this->assertCount(1, $spans); + $span = $spans[0]->getContext(); + $this->assertSame('00-'.$span->getTraceId().'-'.$span->getSpanId().'-01', $entry['context']['carrier']['traceparent']); + $this->assertSame($this->carrier(), $wire['observability_context']); + $this->assertArrayNotHasKey('baggage', $entry['context']['carrier']); + } finally { + $configuration->detach(); + $provider->shutdown(); + } + } + + public function test_bad_original_claims_are_omitted_before_origin_access(): void + { + $runId = $this->start(); + $wire = $this->poll(); + $run = WorkflowRun::query()->findOrFail($runId); + $observed = new ObservedOriginRun; + $observed->setRawAttributes($run->getRawOriginal(), true); + $task = WorkflowTask::query()->findOrFail($wire['task_id']); + foreach ([['lease_owner' => 'wrong-owner'], ['workflow_task_attempt' => 0], + ['workflow_task_attempt' => 2], ['run_id' => 'wrong-run'], + ['lease_expires_at' => now()->subSecond()->toJSON()], + ['lease_expires_at' => now()->addHour()->toJSON()]] as $change) { + $this->assertSame([], WorkflowObservabilityPoll::forClaim($observed, $task, array_replace($wire, $change), 'workflow')); + } + $this->assertSame(0, $observed->originReads); + $this->assertSame($wire['lease_owner'], $task->fresh()->lease_owner); + $this->assertSame($wire['workflow_task_attempt'], $task->fresh()->attempt_count); + } + + public function test_query_observation_requires_original_positive_generation_and_deadlines(): void + { + $runId = $this->start(); + $run = WorkflowRun::query()->findOrFail($runId); + $observed = new ObservedOriginRun; + $observed->setRawAttributes($run->getRawOriginal(), true); + $claim = ['status' => 'leased', 'query_task_id' => 'real-query', 'namespace' => 'default', + 'workflow_id' => 'trace-workflow', 'run_id' => $runId, 'lease_owner' => 'trace-worker', + 'attempt_count' => 1, 'query_admitted_at' => now()->toJSON(), + 'query_deadline_at' => now()->addSeconds(30)->toJSON(), 'lease_expires_at' => now()->addSeconds(10)->toJSON()]; + foreach ([['attempt_count' => 0], ['attempt_count' => true], ['namespace' => 'other'], + ['query_admitted_at' => '1970-01-01T00:00:00Z'], + ['query_deadline_at' => now()->subSecond()->toJSON()], + ['lease_expires_at' => now()->subSecond()->toJSON()]] as $change) { + $this->assertSame([], WorkflowObservabilityPoll::forQuery($observed, array_replace($claim, $change))); + } + $this->assertSame(0, $observed->originReads); + $this->assertSame($this->carrier(), WorkflowObservabilityPoll::forQuery($observed, $claim)['observability_context']); + } + + public function test_cached_delivery_reapplies_current_baggage_and_disable_settings(): void + { + config(['server.observability_context.baggage_allowlist' => ['approved']]); + $carrier = $this->carrier(); + $carrier['carrier']['baggage'] = 'approved=tenant-a'; + $this->start($carrier); + $wire = $this->poll(); + $this->assertSame('approved=tenant-a', $wire['observability_context']['carrier']['baggage']); + config(['server.observability_context.baggage_allowlist' => []]); + $filtered = WorkflowObservabilityPoll::filterDelivered($wire); + $this->assertArrayNotHasKey('baggage', $filtered['observability_context']['carrier']); + config(['server.observability_context.enabled' => false]); + $filtered = WorkflowObservabilityPoll::filterDelivered($wire); + $this->assertArrayNotHasKey('observability_context', $filtered); + foreach (['task_id', 'run_id', 'workflow_task_attempt', 'lease_owner', 'lease_expires_at', 'history_events'] as $field) { + $this->assertSame($wire[$field], $filtered[$field]); + } + } + + public function test_malformed_links_only_set_a_bounded_disposition_on_the_existing_span(): void + { + $this->start(); + $wire = $this->poll(); + $exporter = new InMemoryExporter; + $provider = new TracerProvider([new SimpleSpanProcessor($exporter)]); + $span = $provider->getTracer('workflow773-server-test')->spanBuilder('existing-business-scope')->startSpan(); + $scope = $span->activate(); + try { + $wire['observability_links'] = ['schema' => 'unknown', 'secret' => 'must-not-be-exported']; + $filtered = WorkflowObservabilityPoll::filterDelivered($wire); + $this->assertArrayNotHasKey('observability_links', $filtered); + unset($wire['observability_links']); + $this->assertSame($wire, $filtered); + } finally { + $scope->detach(); + $span->end(); + $provider->shutdown(); + } + $this->assertCount(1, $exporter->getSpans()); + $attributes = $exporter->getSpans()[0]->getAttributes()->toArray(); + $this->assertSame(['durable.trace_context.disposition' => 'discarded'], $attributes); + } + + private function start(?array $carrier = null): string + { + return $this->withHeaders($this->apiHeaders())->postJson('/api/workflows', [ + 'workflow_id' => 'trace-workflow', 'workflow_type' => 'tests.interactive-command-workflow', + 'task_queue' => 'default', 'observability_context' => $carrier ?? $this->carrier(), + ])->assertCreated()->json('run_id'); + } + + private function poll(): array + { + return $this->withHeaders($this->workerHeaders())->postJson('/api/worker/workflow-tasks/poll', [ + 'worker_id' => 'trace-worker', 'task_queue' => 'default', 'timeout_seconds' => 0, + ])->assertOk()->json('task'); + } + + private function carrier(string $traceId = '4bf92f3577b34da6a3ce929d0e0e4736'): array + { + return ['schema' => 'durable-workflow.trace-context', 'version' => 1, + 'carrier' => ['traceparent' => '00-'.$traceId.'-00f067aa0ba902b7-01']]; + } +} + +class ObservedOriginRun extends WorkflowRun +{ + public int $originReads = 0; + + public function getAttribute($key) + { + if ($key === 'observability_context') { + $this->originReads++; + } + + return parent::getAttribute($key); + } +} diff --git a/tests/Feature/WorkflowObservabilityTransitionsTest.php b/tests/Feature/WorkflowObservabilityTransitionsTest.php new file mode 100644 index 000000000..ba5f093b9 --- /dev/null +++ b/tests/Feature/WorkflowObservabilityTransitionsTest.php @@ -0,0 +1,98 @@ + true, 'server.observability_context.baggage_allowlist' => []]); + if (! WorkflowObservability::enabled()) { + if (getenv('DW_TEST_OBSERVABILITY_CONTEXT_REQUIRED') === '1') { + $this->fail('This qualification requires the tracing Core source and optional API.'); + } + $this->markTestSkipped('Requires the optional tracing Core/API dependency profile.'); + } + Queue::fake(); + $this->createNamespace('default'); + $this->configureWorkflowTypes(['tests.interactive-command-workflow' => InteractiveCommandWorkflow::class]); + $this->registerWorker('transition-worker', 'default', supportedWorkflowTypes: ['tests.interactive-command-workflow']); + } + + public static function transitions(): array + { + return [ + 'new child producer' => ['start_child_workflow', true, false], + 'new continued producer' => ['continue_as_new', true, false], + 'disabled child inherits' => ['start_child_workflow', false, false], + 'disabled continuation inherits' => ['continue_as_new', false, false], + 'invalid child metadata inherits' => ['start_child_workflow', true, true], + 'invalid continued metadata inherits' => ['continue_as_new', true, true], + ]; + } + + #[DataProvider('transitions')] + public function test_only_new_runs_receive_their_producer_while_business_payloads_and_retained_origins_stay_fixed( + string $type, bool $enabled, bool $malformed, + ): void { + $origin = $this->carrier('4bf92f3577b34da6a3ce929d0e0e4736'); + $start = $this->withHeaders($this->apiHeaders())->postJson('/api/workflows', [ + 'workflow_id' => 'transition-origin', 'workflow_type' => 'tests.interactive-command-workflow', + 'task_queue' => 'default', 'observability_context' => $origin, + ])->assertCreated(); + $parentId = $start->json('run_id'); + $task = $this->withHeaders($this->workerHeaders())->postJson('/api/worker/workflow-tasks/poll', [ + 'worker_id' => 'transition-worker', 'task_queue' => 'default', 'timeout_seconds' => 0, + ])->assertOk()->json('task'); + $this->assertIsArray($task); + config(['server.observability_context.enabled' => $enabled]); + $producer = $this->carrier('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + $arguments = [['observability_context' => 'ordinary business value', 'integer' => 9007199254740993, 'unicode' => '雪']]; + $command = ['type' => $type, 'arguments' => ['codec' => 'avro', 'blob' => Serializer::serializeWithCodec('avro', $arguments)], + 'observability_context' => $malformed ? ['schema' => 'unknown'] : $producer]; + if ($type === 'start_child_workflow') { + $command['workflow_type'] = 'remote.observed-child'; + } + $body = ['lease_owner' => 'transition-worker', 'workflow_task_attempt' => $task['workflow_task_attempt'], 'commands' => [$command]]; + $path = '/api/worker/workflow-tasks/'.$task['task_id'].'/complete'; + $this->withHeaders($this->workerHeaders())->postJson($path, $body)->assertOk()->assertJsonPath('recorded', true); + $link = WorkflowLink::query()->where('parent_workflow_run_id', $parentId) + ->where('link_type', $type === 'start_child_workflow' ? 'child_workflow' : 'continue_as_new')->sole(); + $child = WorkflowRun::query()->findOrFail($link->child_workflow_run_id); + $this->assertSame($enabled && ! $malformed ? $producer : $origin, $child->observability_context); + $this->assertSame($origin, WorkflowRun::query()->findOrFail($parentId)->observability_context); + $this->assertSame($arguments, Serializer::unserializeWithCodec($child->payload_codec, $child->arguments)); + $history = json_encode(WorkflowHistoryEvent::query()->whereIn('workflow_run_id', [$parentId, $child->id])->get()->toArray(), JSON_THROW_ON_ERROR); + $this->assertStringNotContainsString('durable-workflow.trace-context', $history); + $this->assertStringNotContainsString('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $history); + $before = $child->getRawOriginal(); + $runCount = WorkflowRun::query()->count(); + $body['commands'][0]['observability_context'] = $this->carrier('cccccccccccccccccccccccccccccccc'); + $retry = $this->withHeaders($this->workerHeaders())->postJson($path, $body); + $this->assertContains($retry->getStatusCode(), [200, 409]); + $this->assertSame($before, $child->fresh()->getRawOriginal()); + $this->assertSame($runCount, WorkflowRun::query()->count()); + } + + private function carrier(string $traceId): array + { + return ['schema' => 'durable-workflow.trace-context', 'version' => 1, + 'carrier' => ['traceparent' => '00-'.$traceId.'-00f067aa0ba902b7-01']]; + } +} From f092e8e0f94aab3c6c36b7009cd8628437f078ae Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 23:49:14 +0000 Subject: [PATCH 3/6] Separate trace envelope validation and committed transition attachment --- app/Http/Controllers/Api/WorkerController.php | 2 +- app/Support/WorkflowObservabilityPoll.php | 28 +++++++++++------- .../WorkflowObservabilityTransitions.php | 29 +++++++++++-------- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/app/Http/Controllers/Api/WorkerController.php b/app/Http/Controllers/Api/WorkerController.php index b63c9c7e8..9f63b4ac5 100644 --- a/app/Http/Controllers/Api/WorkerController.php +++ b/app/Http/Controllers/Api/WorkerController.php @@ -4410,7 +4410,7 @@ public function pollQueryTasks(Request $request): JsonResponse } return WorkerProtocol::json([ - 'task' => is_array($poll['task']) ? WorkflowObservabilityPoll::filterDelivered($poll['task']) : null, + 'task' => WorkflowObservabilityPoll::filterOptionalDelivered($poll['task']), 'poll_status' => $poll['poll_status'], ]); } diff --git a/app/Support/WorkflowObservabilityPoll.php b/app/Support/WorkflowObservabilityPoll.php index 045668511..b0eeff8da 100644 --- a/app/Support/WorkflowObservabilityPoll.php +++ b/app/Support/WorkflowObservabilityPoll.php @@ -16,6 +16,11 @@ /** Optional observations after the poller's original claim and history fences. */ final class WorkflowObservabilityPoll { + public static function filterOptionalDelivered(mixed $wire): ?array + { + return is_array($wire) ? self::filterDelivered($wire) : null; + } + /** Reapply current privacy settings to a validated cached poll response. */ public static function filterDelivered(array $wire): array { @@ -287,23 +292,24 @@ private static function validEntry(array $entry): bool return false; } foreach ($entry as $key => $value) { - if ($key === 'context') { - continue; - } - if ($key === 'sequence') { - if (! is_int($value) || $value < 1) { - return false; - } - } elseif ($key !== 'child_task_id' || $value !== null) { - if (! self::identity($value)) { - return false; - } + if (! self::validEntryField($key, $value)) { + return false; } } return true; } + private static function validEntryField(string $key, mixed $value): bool + { + return match ($key) { + 'context' => true, + 'sequence' => is_int($value) && $value > 0, + 'child_task_id' => $value === null || self::identity($value), + default => self::identity($value), + }; + } + private static function normalizeLinks(mixed $value, string $taskId, mixed $runId, mixed $namespace): ?array { if (! is_array($value) || count($value) !== 6 || ! self::identity($runId) || ! self::identity($namespace) diff --git a/app/Support/WorkflowObservabilityTransitions.php b/app/Support/WorkflowObservabilityTransitions.php index c8224a14e..eb24926e2 100644 --- a/app/Support/WorkflowObservabilityTransitions.php +++ b/app/Support/WorkflowObservabilityTransitions.php @@ -3,6 +3,7 @@ namespace App\Support; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Throwable; use Workflow\V2\Enums\TaskStatus; use Workflow\V2\Models\WorkflowHistoryEvent; @@ -49,18 +50,7 @@ public static function apply(Request $request, WorkflowTask $original, array $cr $producers = WorkflowObservability::enabled() ? self::producers($request) : []; foreach (['child_workflow', 'continue_as_new'] as $kind) { - $selected = $links->where('link_type', $kind)->values(); - $contexts = $producers[$kind] ?? []; - foreach ($selected as $index => $link) { - $expectedEvent = $kind === 'child_workflow' ? 'ChildWorkflowScheduled' : 'WorkflowContinuedAsNew'; - $event = $events->first(static fn (WorkflowHistoryEvent $event): bool => $event->event_type->value === $expectedEvent - && ($event->payload['workflow_link_id'] ?? null) === $link->id); - if ($event === null) { - continue; - } - $context = count($contexts) === $selected->count() ? ($contexts[$index] ?? null) : null; - self::writeOrigin($link, $parent, $context ?? $inherited); - } + self::applyKind($kind, $links, $events, $producers[$kind] ?? [], $parent, $inherited); } }); } catch (Throwable) { @@ -68,6 +58,21 @@ public static function apply(Request $request, WorkflowTask $original, array $cr } } + private static function applyKind( + string $kind, Collection $links, Collection $events, array $contexts, WorkflowRun $parent, ?array $inherited, + ): void { + $selected = $links->where('link_type', $kind)->values(); + $expectedEvent = $kind === 'child_workflow' ? 'ChildWorkflowScheduled' : 'WorkflowContinuedAsNew'; + foreach ($selected as $index => $link) { + $event = $events->first(static fn (WorkflowHistoryEvent $event): bool => $event->event_type->value === $expectedEvent + && ($event->payload['workflow_link_id'] ?? null) === $link->id); + if ($event !== null) { + $context = count($contexts) === $selected->count() ? ($contexts[$index] ?? null) : null; + self::writeOrigin($link, $parent, $context ?? $inherited); + } + } + } + private static function producers(Request $request): array { $result = ['child_workflow' => [], 'continue_as_new' => []]; From 942840e70592380323cc7273af1c5541df9062f4 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 21 Oct 2026 03:15:25 +0000 Subject: [PATCH 4/6] Adopt published Core tracing in optional PHP Server 2.10 --- README.md | 7 +++ composer.json | 4 +- composer.lock | 25 ++++++---- docker-compose.dedicated-matching.yml | 4 +- docker-compose.memo-rolling.yml | 4 +- docker-compose.published.yml | 4 +- docker-compose.small-cluster.yml | 2 +- docker-compose.yml | 8 +-- docs/observability-context.md | 50 +++++++++++++++++-- k8s/README.md | 10 ++-- k8s/helm/durable-workflow/Chart.yaml | 12 ++--- k8s/helm/durable-workflow/README.md | 2 +- .../ci/existing-secrets-values.yaml | 2 +- .../ci/ingress-and-hpa-values.yaml | 2 +- .../ci/inline-secrets-values.yaml | 2 +- k8s/helm/durable-workflow/docs/UPGRADING.md | 15 ++++++ .../durable-workflow/templates/_helpers.tpl | 2 +- k8s/helm/durable-workflow/values.yaml | 2 +- k8s/helm/examples/values-dev.yaml | 2 +- .../values-external-secrets-operator.yaml | 2 +- .../values-production-existing-secrets.yaml | 2 +- k8s/migration-job.yaml | 2 +- k8s/scheduler-cronjob.yaml | 2 +- k8s/secret.yaml | 2 +- k8s/server-deployment.yaml | 2 +- k8s/worker-deployment.yaml | 2 +- resources/release/source-release.json | 4 +- scripts/k8s-kind-smoke.sh | 2 +- 28 files changed, 126 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index fc0d80e1b..94e8dfe8a 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,13 @@ generic "workflows per second" number cannot hide the workload being measured. - [External payload storage contract](docs/contracts/external-payload-storage.md) - [Helm upgrade guide](k8s/helm/durable-workflow/docs/UPGRADING.md) +## Optional trace context + +A configured application image can persist bounded W3C context across compatible +SDK workers. Enable the optional API and discover the actual capability as +explained in [PHP Server trace context](docs/observability-context.md). The guide +covers nullable schema migrations, baggage controls and runtime boundaries. + ## Development ```bash diff --git a/composer.json b/composer.json index 45646eb1f..e40bcd2d7 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": "^8.2", "apache/avro": "^1.12", - "durable-workflow/workflow": "2.8.0", + "durable-workflow/workflow": "2.9.0", "laravel/framework": "^13.30", "laravel/tinker": "^3.0", "league/flysystem-aws-s3-v3": "^3.35.3" @@ -48,7 +48,7 @@ }, "extra": { "durable-workflow": { - "product-train": "2.9.11" + "product-train": "2.10.0" }, "laravel": { "dont-discover": [] diff --git a/composer.lock b/composer.lock index 65d643599..a76e2a3f7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f19e9ba3a401c157a88a1c27b5a1e639", + "content-hash": "6efd01e2e4b94293e21ace88e7088f04", "packages": [ { "name": "apache/avro", @@ -655,16 +655,16 @@ }, { "name": "durable-workflow/workflow", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "2edaa6b1c6f45fcec4570813c2e741aadde92b3b" + "reference": "2bc1ba0b57df34b74de02427d2225bd8e4dc6322" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/2edaa6b1c6f45fcec4570813c2e741aadde92b3b", - "reference": "2edaa6b1c6f45fcec4570813c2e741aadde92b3b", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/2bc1ba0b57df34b74de02427d2225bd8e4dc6322", + "reference": "2bc1ba0b57df34b74de02427d2225bd8e4dc6322", "shasum": "" }, "require": { @@ -674,18 +674,25 @@ "react/promise": "^2.9|^3.0", "symfony/yaml": "^6.0|^7.0|^8.0" }, + "conflict": { + "open-telemetry/api": "<1.10.1 || >=2.0" + }, "replace": { "laravel-workflow/laravel-workflow": "self.version" }, "require-dev": { "aws/aws-sdk-php": "^3.0", "doctrine/dbal": "^3.7", + "open-telemetry/api": "^1.10.1", + "open-telemetry/exporter-otlp": "^1.4", + "open-telemetry/sdk": "^1.0", "orchestra/testbench": "^8.0|^11.0", "phpstan/phpstan": "^2.0", "symplify/easy-coding-standard": "^11.0" }, "suggest": { - "durable-workflow/sdk": "Framework-neutral client and remote-worker SDK for the standalone Durable Workflow server." + "durable-workflow/sdk": "Framework-neutral client and remote-worker SDK for the standalone Durable Workflow server.", + "open-telemetry/api": "Optional application-owned trace propagation API, version 1.10.1 or later in the 1.x line." }, "type": "library", "extra": { @@ -698,7 +705,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.8.0", + "product-train": "2.9.0", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -725,9 +732,9 @@ "description": "Embedded durable workflow runtime and orchestration engine for Laravel applications.", "support": { "issues": "https://github.com/durable-workflow/workflow/issues", - "source": "https://github.com/durable-workflow/workflow/tree/2.8.0" + "source": "https://github.com/durable-workflow/workflow/tree/2.9.0" }, - "time": "2026-10-20T21:24:05+00:00" + "time": "2026-10-21T02:33:14+00:00" }, { "name": "egulias/email-validator", diff --git a/docker-compose.dedicated-matching.yml b/docker-compose.dedicated-matching.yml index 02585c5eb..77b4b2b41 100644 --- a/docker-compose.dedicated-matching.yml +++ b/docker-compose.dedicated-matching.yml @@ -32,13 +32,13 @@ name: durable-workflow-server # daemon reports `shape: dedicated`. # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.9.11}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.10.0}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.9.11}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.10.0}} APP_DEBUG: ${APP_DEBUG:-false} DB_CONNECTION: mysql DB_HOST: mysql diff --git a/docker-compose.memo-rolling.yml b/docker-compose.memo-rolling.yml index a4369093e..1e43c93b7 100644 --- a/docker-compose.memo-rolling.yml +++ b/docker-compose.memo-rolling.yml @@ -49,14 +49,14 @@ services: command: ["server-bootstrap"] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.9.11} + APP_VERSION: ${APP_VERSION:-2.10.0} successor: image: ${DW_MEMO_SUCCESSOR_IMAGE:-durable-workflow/server-memo-rolling:local} ports: !override [] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.9.11} + APP_VERSION: ${APP_VERSION:-2.10.0} DW_SERVER_ID: memo-successor DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node diff --git a/docker-compose.published.yml b/docker-compose.published.yml index eae4f8c75..7514a273a 100644 --- a/docker-compose.published.yml +++ b/docker-compose.published.yml @@ -1,13 +1,13 @@ name: durable-workflow-server # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.9.11}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.10.0}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.9.11}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.10.0}} APP_DEBUG: ${APP_DEBUG:-false} LOG_CHANNEL: ${LOG_CHANNEL:-stderr} LOG_LEVEL: ${LOG_LEVEL:-info} diff --git a/docker-compose.small-cluster.yml b/docker-compose.small-cluster.yml index 26da0da10..5750b765b 100644 --- a/docker-compose.small-cluster.yml +++ b/docker-compose.small-cluster.yml @@ -12,7 +12,7 @@ x-server-build: &server-build x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: testing - APP_VERSION: ${APP_VERSION:-2.9.11} + APP_VERSION: ${APP_VERSION:-2.10.0} APP_DEBUG: "false" DW_SERVER_KEY: ${DW_SERVER_KEY:-base64:5Zt4nUhlCm3DD0nLXZJQdHiwPfb56yGo9gNV/g3jYbY=} DB_CONNECTION: ${DW_SMALL_CLUSTER_DB:-mysql} diff --git a/docker-compose.yml b/docker-compose.yml index 152bde920..ff6b2bb1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node - APP_VERSION: "${APP_VERSION:-2.9.11}" + APP_VERSION: "${APP_VERSION:-2.10.0}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -62,7 +62,7 @@ services: APP_NAME: "Durable Workflow Server" APP_ENV: local DW_SERVER_KEY: "${DW_SERVER_KEY:-}" - APP_VERSION: "${APP_VERSION:-2.9.11}" + APP_VERSION: "${APP_VERSION:-2.10.0}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -124,7 +124,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: worker_node - APP_VERSION: "${APP_VERSION:-2.9.11}" + APP_VERSION: "${APP_VERSION:-2.10.0}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 @@ -178,7 +178,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: scheduler_node - APP_VERSION: "${APP_VERSION:-2.9.11}" + APP_VERSION: "${APP_VERSION:-2.10.0}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 diff --git a/docs/observability-context.md b/docs/observability-context.md index 0586b4a44..9682ada26 100644 --- a/docs/observability-context.md +++ b/docs/observability-context.md @@ -1,6 +1,6 @@ -# Optional trace context preparation +# Optional PHP Server trace context -This is draft PHP Server work for [Workflow #773](https://github.com/durable-workflow/workflow/issues/773). It does not claim published service-mode tracing support. The draft implementation advertises its optional capability only when explicitly enabled with the required API and actual nullable columns available. The source profile uses the unpublished Core tracing branch; stable delivery must use a qualified published Core package and a separately qualified Server/SDK tuple. +PHP Server 2.10 adds opt-in durable trace context using the published Core tracing package. Enable it only on a qualified application image with the optional OpenTelemetry API and actual nullable carrier columns. Workers and clients must discover the exact capability; a version number alone does not enable propagation. [Workflow #773](https://github.com/durable-workflow/workflow/issues/773) records the published Server/SDK tuple and its acceptance results. Earlier Server images do not gain this capability from an SDK update. The implementation persists an optional caller origin on a newly admitted run or command. Authentication, namespace and business admission checks precede extraction. Metadata is attached to the existing business insert, using Core's nullable carrier columns. Returning an existing run does not replace its origin. A rejected or retained operation gains no tracing row or replacement metadata. @@ -12,6 +12,36 @@ Authorized workflow, activity and query polls deliver the run origin only after Set `DW_OBSERVABILITY_CONTEXT_ENABLED=true` only on an application image that installs `open-telemetry/api:^1.10.1` and the qualified Core tracing package. The ordinary Server manifest does not require this API. The API alone does not export spans; the application owns its SDK/exporter configuration. Set `DW_OBSERVABILITY_BAGGAGE_ALLOWLIST='["tenant.id"]'` to permit an explicit key; malformed or non-list configuration permits none. Config caching preserves both controls. Disabling delivery strips cached carriers without modifying business fields, original leases or stored histories. +## Build an application image with the optional API + +The ordinary image keeps the API optional. Start with the immutable published +Server image from its release receipt, then install the API in your application +image. This example uses Composer as UID 1000; root changes file ownership only. + +```dockerfile +ARG SERVER_IMAGE +FROM ${SERVER_IMAGE} +USER root +RUN chown 1000:1000 /app/composer.json /app/composer.lock \ + && chown -R 1000:1000 /app/vendor +USER 1000:1000 +RUN COMPOSER_HOME=/tmp/composer COMPOSER_CACHE_DIR=/tmp/composer-cache \ + composer require 'open-telemetry/api:^1.10.1' \ + --no-dev --no-scripts --no-plugins --prefer-dist --no-interaction --no-progress \ + && rm -rf /tmp/composer /tmp/composer-cache +``` + +Set `SERVER_IMAGE` to the immutable image reference from the release receipt and +pass it with `docker build --build-arg SERVER_IMAGE="$SERVER_IMAGE"`. Bootstrap +that application image normally, then enable the flag on its HTTP and worker +roles and refresh their configuration caches. The API can validate and persist +context without an exporter. Configure your own providers and bounded exporters +when the Server itself should emit spans. A locally built application image is +a configured consumer of the published base; it is not another official Server +release digest. + +## Capability discovery + Discovery exposes `worker_protocol.server_capabilities.observability_context` only for the enabled PHP implementation with the required API and all three actual nullable carrier columns. Its exact value is: ```json @@ -20,4 +50,18 @@ Discovery exposes `worker_protocol.server_capabilities.observability_context` on This capability adds no mandatory worker protocol floor. Workers must treat a missing, malformed or unknown capability as unavailable and must not infer it from a version string. Bounded discovery failure never changes business retries or admission. A fixed `discarded` or `filtered` disposition may be attached to an existing recording span; no diagnostic span or raw metadata log is created. -Native catalogs, shared/frozen fixtures, Cloud adoption and native migration/cutover remain outside this PHP implementation. Its release follows Server #552's qualified Core 2.8 adoption and requires fresh combined-source checks. +## Schema upgrade and runtime boundaries + +The package migration adds nullable `observability_context` columns to the run, +command and task tables even when tracing is disabled. Back up before upgrading, +run the ordinary Server bootstrap, and verify those migrations on every +configured workflow storage connection. Existing rows retain null metadata and +their business/history values. Disabling tracing omits carriers from responses; +it does not delete stored context or roll back the schema. + +Native catalogs, shared/frozen fixtures, Cloud adoption and native +migration/cutover have separate qualification. Preserve the PHP native-ownership +fence on every connector. The [native layout decision](https://github.com/durable-workflow/server/issues/325#issuecomment-6306545520) +acknowledges these PHP columns but does not establish an executed native takeover +or native carrier support. Use the native owner's published layout and conversion +qualification before attempting that migration. diff --git a/k8s/README.md b/k8s/README.md index a339e837a..b58d07ed8 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -13,7 +13,7 @@ The checked-in manifests are synchronized with the repository's stable source release and pin its Docker Hub tag: ```text -durableworkflow/server:2.9.11 +durableworkflow/server:2.10.0 ``` Before production use, patch every workload image to the exact published tag or @@ -21,15 +21,15 @@ digest you intend to run: ```bash kubectl set image -n durable-workflow deploy/durable-workflow-server \ - server=durableworkflow/server:2.9.11 + server=durableworkflow/server:2.10.0 kubectl set image -n durable-workflow deploy/durable-workflow-worker \ - worker=durableworkflow/server:2.9.11 + worker=durableworkflow/server:2.10.0 kubectl set image -n durable-workflow cronjob/durable-workflow-scheduler \ - scheduler=durableworkflow/server:2.9.11 + scheduler=durableworkflow/server:2.10.0 ``` GitHub Container Registry publishes the same release line at -`ghcr.io/durable-workflow/server:2.9.11`. Digest pinning is preferred for strict +`ghcr.io/durable-workflow/server:2.10.0`. Digest pinning is preferred for strict change control. The manifests expect you to provide: diff --git a/k8s/helm/durable-workflow/Chart.yaml b/k8s/helm/durable-workflow/Chart.yaml index 2b41a6ff3..9f0ea4438 100644 --- a/k8s/helm/durable-workflow/Chart.yaml +++ b/k8s/helm/durable-workflow/Chart.yaml @@ -5,11 +5,11 @@ type: application # The chart's own semver version. Bumped on every chart release; treated as # independent of the server image version (appVersion). Breaking-change rules # for this version live in docs/UPGRADING.md alongside the chart. -version: 0.2.1 +version: 0.2.2 # The immutable Durable Workflow Server identity this chart release packages. # The onboarding default in values.yaml and appVersion are generated from the # checked-in source release record. -appVersion: "2.9.11" +appVersion: "2.10.0" kubeVersion: ">=1.27.0-0" home: https://durable-workflow.github.io/docs/2.0/deployment sources: @@ -30,12 +30,12 @@ annotations: # exact commit that most recently changed the packaged chart. org.opencontainers.image.source: https://github.com/durable-workflow/server dev.durable-workflow.source-revision: "unreleased" - dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.9.11" + dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.10.0" artifacthub.io/license: MIT artifacthub.io/category: integration-delivery # Free-form changelog for the current chart release shown by Artifact Hub. artifacthub.io/changes: | - - kind: fixed - description: Enforce configured database TLS modes and refuse invalid trust, hostname or plaintext fallback. + - kind: changed + description: Package the Server release with optional PHP observability context and nullable carrier migrations. - kind: added - description: Mount an existing database CA Secret read-only in server, worker, bootstrap and scheduler roles. + description: Document application-owned API configuration and capability discovery without adding a required dependency. diff --git a/k8s/helm/durable-workflow/README.md b/k8s/helm/durable-workflow/README.md index 4cb1730c5..61b0d7a20 100644 --- a/k8s/helm/durable-workflow/README.md +++ b/k8s/helm/durable-workflow/README.md @@ -63,7 +63,7 @@ helm install durable-workflow ./k8s/helm/durable-workflow \ ```yaml image: - tag: "2.9.11" + tag: "2.10.0" # Pin a digest in production: # digest: "sha256:abc123..." # memoPayloadStorage: "raw-json-v1" # Required for a digest or custom image. diff --git a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml index 5744abf7c..9072e9a18 100644 --- a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml @@ -1,7 +1,7 @@ # CI fixture: GitOps / externally-managed-secret path. The chart consumes # existing Secrets and renders no Secret resources of its own. image: - tag: "2.9.11" + tag: "2.10.0" externalDatabase: connection: pgsql diff --git a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml index 12508cbac..b1d930ed5 100644 --- a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml +++ b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml @@ -1,6 +1,6 @@ # CI fixture: ingress + autoscaling enabled. Exercises optional templates. image: - tag: "2.9.11" + tag: "2.10.0" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml index 7f09c5c55..9031db341 100644 --- a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml @@ -2,7 +2,7 @@ # chart's render path is exercised end-to-end. Real deployments should use # existingSecret instead. image: - tag: "2.9.11" + tag: "2.10.0" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/docs/UPGRADING.md b/k8s/helm/durable-workflow/docs/UPGRADING.md index 73ecc0bcd..3967e939c 100644 --- a/k8s/helm/durable-workflow/docs/UPGRADING.md +++ b/k8s/helm/durable-workflow/docs/UPGRADING.md @@ -67,6 +67,21 @@ chart MINOR bump and an entry below. ## Per-version migration notes +### Chart 0.2.2 / Server 2.10 + +This chart changes the default application image without removing or renaming +chart values. Server bootstrap adds Core's nullable context columns to runs, +commands and tasks, including installations with tracing disabled. Back up the +database before the ordinary bootstrap and verify migration completion. + +The ordinary image keeps OpenTelemetry optional. Use an application image with +the supported API, configure the flag through the existing environment controls, +and require actual capability discovery before sending carriers. See +[PHP Server trace context](../../../../docs/observability-context.md) for setup +and the separate native migration boundary. This chart does not qualify native +catalog conversion or Cloud deployment. + + ### 0.2.0 Database TLS settings now affect the PHP connection. An old `sslMode` setting diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index 8c6f913a5..e0177287c 100644 --- a/k8s/helm/durable-workflow/templates/_helpers.tpl +++ b/k8s/helm/durable-workflow/templates/_helpers.tpl @@ -88,7 +88,7 @@ resolved by an explicit capability declaration or an existing workload marker. {{- define "durable-workflow.memoPayloadStorageForImage" -}} {{- $image := toString . -}} {{- $normalized := regexReplaceAll "^index\\.docker\\.io/" $image "docker.io/" -}} -{{- if eq $normalized "docker.io/durableworkflow/server:2.9.11" -}} +{{- if eq $normalized "docker.io/durableworkflow/server:2.10.0" -}} dual-v1 {{- else if regexMatch "^docker\\.io/durableworkflow/server:2\\.0\\.0-rc\\.[0-9]+$" $normalized -}} {{- $releaseCandidate := atoi (regexFind "[0-9]+$" $normalized) -}} diff --git a/k8s/helm/durable-workflow/values.yaml b/k8s/helm/durable-workflow/values.yaml index 4b1f4542c..b0f960047 100644 --- a/k8s/helm/durable-workflow/values.yaml +++ b/k8s/helm/durable-workflow/values.yaml @@ -21,7 +21,7 @@ image: registry: docker.io repository: durableworkflow/server # Generated by scripts/ci/sync-source-release.mjs. Do not edit this default. - tag: "2.9.11" + tag: "2.10.0" # Optional digest pin. When set, takes precedence over tag for change control. # Example: "sha256:abc123..." digest: "" diff --git a/k8s/helm/examples/values-dev.yaml b/k8s/helm/examples/values-dev.yaml index 0b92a6a43..68ccfe92e 100644 --- a/k8s/helm/examples/values-dev.yaml +++ b/k8s/helm/examples/values-dev.yaml @@ -3,7 +3,7 @@ # shape in production. image: - tag: "2.9.11" + tag: "2.10.0" externalDatabase: connection: mysql diff --git a/k8s/helm/examples/values-external-secrets-operator.yaml b/k8s/helm/examples/values-external-secrets-operator.yaml index 3e1556191..83b970697 100644 --- a/k8s/helm/examples/values-external-secrets-operator.yaml +++ b/k8s/helm/examples/values-external-secrets-operator.yaml @@ -5,7 +5,7 @@ # concern. image: - tag: "2.9.11" + tag: "2.10.0" externalDatabase: connection: pgsql diff --git a/k8s/helm/examples/values-production-existing-secrets.yaml b/k8s/helm/examples/values-production-existing-secrets.yaml index 58f43599e..b427f40d5 100644 --- a/k8s/helm/examples/values-production-existing-secrets.yaml +++ b/k8s/helm/examples/values-production-existing-secrets.yaml @@ -10,7 +10,7 @@ image: repository: durable-workflow/server # Pin a digest in production for change-control auditability. digest: "" # e.g. "sha256:abc123..." - tag: "2.9.11" + tag: "2.10.0" externalDatabase: connection: pgsql diff --git a/k8s/migration-job.yaml b/k8s/migration-job.yaml index 0141ea47a..8eb2da137 100644 --- a/k8s/migration-job.yaml +++ b/k8s/migration-job.yaml @@ -13,7 +13,7 @@ spec: restartPolicy: OnFailure containers: - name: migrate - image: durableworkflow/server:2.9.11 + image: durableworkflow/server:2.10.0 command: ["server-entrypoint"] args: ["server-bootstrap"] envFrom: diff --git a/k8s/scheduler-cronjob.yaml b/k8s/scheduler-cronjob.yaml index 1acd32e6a..f80da825c 100644 --- a/k8s/scheduler-cronjob.yaml +++ b/k8s/scheduler-cronjob.yaml @@ -24,7 +24,7 @@ spec: restartPolicy: Never containers: - name: scheduler - image: durableworkflow/server:2.9.11 + image: durableworkflow/server:2.10.0 command: ["server-entrypoint"] args: - sh diff --git a/k8s/secret.yaml b/k8s/secret.yaml index 5b9355e78..5084f55ac 100644 --- a/k8s/secret.yaml +++ b/k8s/secret.yaml @@ -12,7 +12,7 @@ metadata: app.kubernetes.io/name: durable-workflow data: APP_NAME: "Durable Workflow Server" - APP_VERSION: "2.9.11" + APP_VERSION: "2.10.0" APP_ENV: production APP_DEBUG: "false" DB_CONNECTION: mysql diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index 285fb96b2..214da09e1 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -23,7 +23,7 @@ spec: spec: containers: - name: server - image: durableworkflow/server:2.9.11 + image: durableworkflow/server:2.10.0 ports: - containerPort: 8080 name: http diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index 89441aac9..84a6df475 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: worker - image: durableworkflow/server:2.9.11 + image: durableworkflow/server:2.10.0 command: ["server-entrypoint"] args: ["php", "artisan", "queue:work", "--sleep=1", "--tries=3", "--max-time=3600"] envFrom: diff --git a/resources/release/source-release.json b/resources/release/source-release.json index 91bd7ecc5..271e80038 100644 --- a/resources/release/source-release.json +++ b/resources/release/source-release.json @@ -1,9 +1,9 @@ { "schema": "durable-workflow.server.source-release/v1", "server": { - "version": "2.9.11" + "version": "2.10.0" }, "helm_chart": { - "version": "0.2.1" + "version": "0.2.2" } } diff --git a/scripts/k8s-kind-smoke.sh b/scripts/k8s-kind-smoke.sh index e35d851bc..3d5efad4e 100755 --- a/scripts/k8s-kind-smoke.sh +++ b/scripts/k8s-kind-smoke.sh @@ -7,7 +7,7 @@ cluster="${K8S_SMOKE_CLUSTER:-durable-workflow-server-smoke}" image="${K8S_SMOKE_IMAGE:-durableworkflow/server:k8s-smoke}" # Generated by scripts/ci/sync-source-release.mjs so the smoke replaces the # same default shipped by the public manifests. -manifest_image="durableworkflow/server:2.9.11" +manifest_image="durableworkflow/server:2.10.0" kind_node_image="${K8S_SMOKE_KIND_NODE_IMAGE:-kindest/node:v1.29.4}" artifact_dir="${K8S_SMOKE_ARTIFACT_DIR:-/tmp/durable-workflow-k8s-kind-smoke-artifacts}" rendered_dir="${artifact_dir}/rendered-manifests" From 157b24dfbe01032cc87972804df51d747aa1e0a2 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 21 Oct 2026 04:42:01 +0000 Subject: [PATCH 5/6] Require a JSON list for the baggage allowlist --- config/server.php | 9 ++-- .../WorkflowObservabilityAdmissionTest.php | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/config/server.php b/config/server.php index 21d8e153d..64ed8d531 100644 --- a/config/server.php +++ b/config/server.php @@ -78,10 +78,11 @@ FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE, ) ?? false, - 'baggage_allowlist' => json_decode( - (string) EnvAuditor::env('DW_OBSERVABILITY_BAGGAGE_ALLOWLIST', null, '[]'), - true, - ) ?? [], + 'baggage_allowlist' => (static function (): array { + $value = json_decode((string) EnvAuditor::env('DW_OBSERVABILITY_BAGGAGE_ALLOWLIST', null, '[]')); + + return is_array($value) ? array_values(array_filter($value, 'is_string')) : []; + })(), ], /* diff --git a/tests/Feature/WorkflowObservabilityAdmissionTest.php b/tests/Feature/WorkflowObservabilityAdmissionTest.php index 5c464bd42..224114e99 100644 --- a/tests/Feature/WorkflowObservabilityAdmissionTest.php +++ b/tests/Feature/WorkflowObservabilityAdmissionTest.php @@ -97,6 +97,48 @@ public function test_default_empty_and_explicit_allowlist_control_baggage(): voi $this->assertStringNotContainsString('private-value', json_encode($carrier, JSON_THROW_ON_ERROR)); } + #[DataProvider('environmentBaggagePolicies')] + public function test_environment_baggage_policy_requires_a_json_list(string $json, ?string $expectedBaggage): void + { + $key = 'DW_OBSERVABILITY_BAGGAGE_ALLOWLIST'; + $original = [getenv($key), $_ENV[$key] ?? null, $_SERVER[$key] ?? null]; + try { + putenv($key.'='.$json); + $_ENV[$key] = $json; + $_SERVER[$key] = $json; + $settings = require config_path('server.php'); + config(['server.observability_context.baggage_allowlist' => $settings['observability_context']['baggage_allowlist']]); + $start = $this->start('environment-baggage', $this->carrier()); + $carrier = WorkflowRun::query()->findOrFail($start->json('run_id'))->observability_context; + $this->assertSame($expectedBaggage, $carrier['carrier']['baggage'] ?? null); + } finally { + $original[0] === false ? putenv($key) : putenv($key.'='.$original[0]); + if ($original[1] === null) { + unset($_ENV[$key]); + } else { + $_ENV[$key] = $original[1]; + } + if ($original[2] === null) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $original[2]; + } + } + } + + public static function environmentBaggagePolicies(): array + { + return [ + 'explicit list' => ['["approved"]', 'approved=tenant-a'], + 'mixed list' => ['["approved", {"key":"private"}, 42]', 'approved=tenant-a'], + 'empty list' => ['[]', null], + 'numeric-key object' => ['{"0":"approved"}', null], + 'named-key object' => ['{"approved":true}', null], + 'malformed JSON' => ['[', null], + 'scalar JSON' => ['"approved"', null], + ]; + } + public function test_retained_or_conflicting_start_never_replaces_an_origin(): void { $start = $this->start('retained', $this->carrier()); From b1adc64cd420dd7082cac135f5ed4399d4c1a2e2 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 21 Oct 2026 05:04:13 +0000 Subject: [PATCH 6/6] Use the supported Composer production update option --- docs/observability-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/observability-context.md b/docs/observability-context.md index 9682ada26..8a62abf7d 100644 --- a/docs/observability-context.md +++ b/docs/observability-context.md @@ -27,7 +27,7 @@ RUN chown 1000:1000 /app/composer.json /app/composer.lock \ USER 1000:1000 RUN COMPOSER_HOME=/tmp/composer COMPOSER_CACHE_DIR=/tmp/composer-cache \ composer require 'open-telemetry/api:^1.10.1' \ - --no-dev --no-scripts --no-plugins --prefer-dist --no-interaction --no-progress \ + --update-no-dev --no-scripts --no-plugins --prefer-dist --no-interaction --no-progress \ && rm -rf /tmp/composer /tmp/composer-cache ```