Skip to content
This repository was archived by the owner on Nov 2, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions php/laravel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion php/laravel/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions php/laravel/config/traceflow.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
];
17 changes: 16 additions & 1 deletion php/laravel/src/Audit/AuditOutbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,31 @@

final class AuditOutbox
{
/** Always enforces an open transaction, whatever the configuration. */
public static function requireTransaction(ConnectionInterface $connection): void
{
if ($connection->transactionLevel() < 1) {
throw new LogicException('Audit requires an explicit transaction on the subject/outbox connection');
}
}

/** 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,
Expand Down
6 changes: 3 additions & 3 deletions php/laravel/src/Audit/LogsAudit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand All @@ -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) {
Expand Down
47 changes: 47 additions & 0 deletions php/laravel/tests/Unit/AuditOutboxTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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']));
Expand Down
Loading