diff --git a/php/laravel/README.md b/php/laravel/README.md index 716ebc8..de52846 100644 --- a/php/laravel/README.md +++ b/php/laravel/README.md @@ -97,6 +97,21 @@ context even on an exception. The trait uses the authenticated actor when none is supplied. Pass trusted context explicitly into queued jobs. Override `customizeAudit(array $event): array` to redact or enrich an event. +### Writes outside transactions + +An application that saves models outside transactions can relax the requirement, +for example while it replaces an activity log written from model events: + +```dotenv +TRACEFLOW_AUDIT_REQUIRE_TRANSACTION=false +``` + +Writes inside a transaction stay atomic. A write outside a transaction stores the +outbox row right after the model write, so a crash or database error between the +two writes loses that event. An outbox error is then raised after the model write +has already been committed. `AuditOutbox::record()` follows the same setting, and +`AuditOutbox::requireTransaction()` always enforces a transaction. + ### Publish the outbox Run this command periodically from the application's scheduler or job runner: diff --git a/php/laravel/composer.json b/php/laravel/composer.json index 411c3e0..bc22999 100644 --- a/php/laravel/composer.json +++ b/php/laravel/composer.json @@ -2,7 +2,7 @@ "name": "smartness/traceflow-laravel", "description": "Production-ready distributed tracing SDK for Laravel with event-sourced architecture, async HTTP transport, and cross-service context propagation", "type": "library", - "version": "2.7.2", + "version": "2.8.0", "keywords": [ "laravel", "tracing", diff --git a/php/laravel/config/traceflow.php b/php/laravel/config/traceflow.php index 1455096..386a6bf 100644 --- a/php/laravel/config/traceflow.php +++ b/php/laravel/config/traceflow.php @@ -103,4 +103,25 @@ 'queue' => [ 'propagate_context' => env('TRACEFLOW_QUEUE_PROPAGATE', true), ], + + /* + |-------------------------------------------------------------------------- + | Audit Configuration + |-------------------------------------------------------------------------- + | + | require_transaction: when true (default), audited model writes and manual + | outbox records must run inside an explicit transaction, so the business + | write and its outbox row commit or roll back together. + | + | When false, a write outside a transaction stores the outbox row right + | after the model write. A crash or database error between the two writes + | loses that event. Writes inside a transaction stay atomic in both modes. + | Use false to adopt auditing in an application that saves models outside + | transactions, with the same guarantee as an activity log written from + | model events. + | + */ + 'audit' => [ + 'require_transaction' => env('TRACEFLOW_AUDIT_REQUIRE_TRANSACTION', true), + ], ]; diff --git a/php/laravel/src/Audit/AuditOutbox.php b/php/laravel/src/Audit/AuditOutbox.php index f741655..ed4b15f 100644 --- a/php/laravel/src/Audit/AuditOutbox.php +++ b/php/laravel/src/Audit/AuditOutbox.php @@ -7,6 +7,7 @@ final class AuditOutbox { + /** Always enforces an open transaction, whatever the configuration. */ public static function requireTransaction(ConnectionInterface $connection): void { if ($connection->transactionLevel() < 1) { @@ -14,9 +15,23 @@ public static function requireTransaction(ConnectionInterface $connection): void } } + /** Whether audited writes must run inside a transaction (traceflow.audit.require_transaction). */ + public static function transactionRequired(): bool + { + return (bool) config('traceflow.audit.require_transaction', true); + } + + /** Enforces the configured policy. Without the requirement, an open transaction is still joined. */ + public static function guardTransaction(ConnectionInterface $connection): void + { + if (self::transactionRequired()) { + self::requireTransaction($connection); + } + } + public function record(AuditEvent $event, ConnectionInterface $connection): void { - self::requireTransaction($connection); + self::guardTransaction($connection); $connection->table('traceflow_audit_outbox')->insert([ 'event_id' => $event->id(), 'payload' => $event->toJson(), 'created_at' => now(), 'available_at' => now(), 'attempts' => 0, diff --git a/php/laravel/src/Audit/LogsAudit.php b/php/laravel/src/Audit/LogsAudit.php index 4c5534c..1d621cf 100644 --- a/php/laravel/src/Audit/LogsAudit.php +++ b/php/laravel/src/Audit/LogsAudit.php @@ -28,11 +28,11 @@ public function customizeAudit(array $event): array public static function bootLogsAudit(): void { static::saving(function ($model) { - AuditOutbox::requireTransaction($model->getConnection()); + AuditOutbox::guardTransaction($model->getConnection()); $model->traceflowAuditBefore = $model->auditSnapshot(true); }); static::deleting(function ($model) { - AuditOutbox::requireTransaction($model->getConnection()); + AuditOutbox::guardTransaction($model->getConnection()); $model->traceflowAuditBefore = $model->auditSnapshot(); }); static::created(fn ($model) => $model->writeAudit('created', [], $model->auditSnapshot())); @@ -44,7 +44,7 @@ public static function bootLogsAudit(): void static::deleted(fn ($model) => $model->writeAudit('deleted', $model->traceflowAuditBefore, [])); if (method_exists(static::class, 'restoring')) { static::restoring(function ($model) { - AuditOutbox::requireTransaction($model->getConnection()); + AuditOutbox::guardTransaction($model->getConnection()); $model->traceflowAuditRestoring = true; }); static::restored(function ($model) { diff --git a/php/laravel/tests/Unit/AuditOutboxTest.php b/php/laravel/tests/Unit/AuditOutboxTest.php index 9a7a03d..383c864 100644 --- a/php/laravel/tests/Unit/AuditOutboxTest.php +++ b/php/laravel/tests/Unit/AuditOutboxTest.php @@ -14,6 +14,7 @@ use Orchestra\Testbench\TestCase; use Smartness\TraceFlow\Audit\AuditClient; use Smartness\TraceFlow\Audit\AuditContext; +use Smartness\TraceFlow\Audit\AuditOutbox; use Smartness\TraceFlow\Audit\LogsAudit; use Smartness\TraceFlow\TraceFlowServiceProvider; @@ -70,6 +71,52 @@ public function test_unwrapped_writes_are_rejected_before_the_subject_is_saved() $this->assertSame(0, AuditTestSubject::count()); } + public function test_optional_transactions_record_unwrapped_model_writes(): void + { + config(['traceflow.audit.require_transaction' => false]); + $model = AuditTestSubject::create(['status' => 'draft']); + $model->update(['status' => 'sent']); + $model->delete(); + $model->restore(); + $events = DB::table('traceflow_audit_outbox')->get()->map(fn ($row) => json_decode($row->payload)); + $actions = $events->pluck('action')->sort()->values()->all(); + $this->assertSame(['created', 'deleted', 'restored', 'updated'], $actions); + $updated = $events->firstWhere('action', 'updated'); + $this->assertSame('draft', $updated->changes->before->status); + $this->assertSame('sent', $updated->changes->after->status); + } + + public function test_optional_transactions_keep_wrapped_writes_atomic(): void + { + config(['traceflow.audit.require_transaction' => false]); + try { + DB::transaction(function () { + AuditTestSubject::create(['status' => 'draft']); + throw new \RuntimeException('rollback'); + }); + } catch (\RuntimeException) { + } + $this->assertSame(0, AuditTestSubject::count()); + $this->assertSame(0, DB::table('traceflow_audit_outbox')->count()); + } + + public function test_manual_records_follow_the_configured_policy(): void + { + $event = app(AuditClient::class)->createEvent(['subject' => ['type' => 'quote', 'id' => '1'], 'action' => 'exported']); + try { + app(AuditOutbox::class)->record($event, DB::connection()); + $this->fail('Expected transaction requirement'); + } catch (\LogicException) { + } + $this->assertSame(0, DB::table('traceflow_audit_outbox')->count()); + config(['traceflow.audit.require_transaction' => false]); + app(AuditOutbox::class)->record($event, DB::connection()); + $this->assertSame(1, DB::table('traceflow_audit_outbox')->count()); + // The explicit check keeps enforcing a transaction for callers that rely on it. + $this->expectException(\LogicException::class); + AuditOutbox::requireTransaction(DB::connection()); + } + public function test_model_changes_are_frozen_and_only_selected_fields_are_audited(): void { $model = DB::transaction(fn () => AuditTestSubject::create(['status' => 'draft', 'secret' => 'example-excluded-value']));