Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add correlation trail #18

Merged
merged 1 commit into from
Nov 22, 2021
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- "correlationTrail" in the audit request body.
- A "X-Correlation-Trail" header in the http macro `withCorrelation`.

### Changed
- **BREAKING**: Renamed macro `withCorrelationId` to `withCorrelation`.
- **BREAKING**: Renamed class `WithCorrelationId` to `WithCorrelation`.
- **BREAKING**: Renamed class `SetCorrelationId` to `SetCorrelation`.

## [0.4.1] - 2021-11-12

Expand Down
47 changes: 40 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ Authorization: Bearer secret

{
"correlationId": "d9afea6a-14ed-4777-ae2f-a4d8baf4d5b7",
"correlationTrail": "Mv9Jd6VM:GaIngT2j",
"entities": [
{
"type": "user",
"identifier": 1
}
],
"event": "subscribed",
"event": "user.subscribed",
"eventContext": [
{
"key": "months",
Expand Down Expand Up @@ -124,31 +125,63 @@ audit($user)->subscribed(); // equivalent to audit(['user', 1])->subscribed();

We use "X-Correlation-ID" header to "relate" audits.

A "X-Correlation-Trail" header is also used to figure out the order of events
without relying on the events `occured_at`, see the example json below.

### Http client macro

Use the "withCorrelationId" macro to add the "X-Correlation-ID" header when sending requests with the Http client.
Use the "withCorrelation" macro to add the "X-Correlation-ID" header when sending requests with the Http client.

In the example below, both "audits" will have the same correlation-id.
In the example below, all "audits" will have the same correlation-id.

```php
// Service A
audit($user)->signedUp();
Http::withCorrelationId()->post('https://service-b.example/welcome-email', $user);
Http::withCorrelation()->post('https://service-b.example/welcome-email', $user);

// Service B
audit($user)->welcomed();
Http::withCorrelation()->post('https://service-c.example/notify-staff');

// Service C
audit($employee)->notified();
```

The requests sent to your configured `BUTLER_AUDIT_URL` will look something like:

```json
{
"initiator": "api",
"event": "user.signedUp",
"correlationId": "92a55a99-82c1-4129-a587-96006f6aac82",
"correlationTrail": null
}

{
"initiator": "service-a",
"event": "user.welcomed",
"correlationId": "92a55a99-82c1-4129-a587-96006f6aac82",
"correlationTrail": "Mv9Jd6VM"
}

{
"initiator": "service-b",
"event": "employee.notified",
"correlationId": "92a55a99-82c1-4129-a587-96006f6aac82",
"correlationTrail": "Mv9Jd6VM:GaIngT2j"
}
```

### Queued jobs

The trait `WithCorrelationId` can be used on queable jobs that needs the same correlation id as the request.
The trait `WithCorrelation` can be used on queable jobs that needs the same correlation id as the request.

#### How it works

1. A job using the `WithCorrelationId` trait is dispatched to the queue.
1. A job using the `WithCorrelation` trait is dispatched to the queue.
1. Our `Dispatcher` will set a `correlationId` property on the job.
1. The job is handled by a worker.
1. The middleware `SetCorrelationId` will tell `Auditor` to use the correlation id from the job.
1. The middleware `SetCorrelation` will tell `Auditor` to use the correlation id from the job.

Extending the dispatcher can be disabled by setting `butler.audit.extend_bus_dispatcher` to `false`.

Expand Down
1 change: 1 addition & 0 deletions src/Audit.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ public function toArray(): array

return [
'correlationId' => $this->auditor->correlationId(),
'correlationTrail' => $this->auditor->correlationTrail(),
'entities' => $this->entities,
'event' => $this->event,
'eventContext' => $this->eventContext,
Expand Down
32 changes: 32 additions & 0 deletions src/Auditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Auditor
}

protected $correlationId;
protected $correlationTrail;

protected bool $recording = false;

Expand Down Expand Up @@ -97,6 +98,37 @@ public function correlationId(?string $correlationId = null): string
return $this->correlationId ??= request()->header('X-Correlation-ID', (string) Str::uuid());
}

public function correlationTrail(?string $correlationTrail = null): ?string
{
if (func_num_args() === 1) {
$this->correlationTrail = $correlationTrail;
}

if ($this->correlationTrail) {
return $this->correlationTrail;
}

if (! request()->hasHeader('X-Correlation-ID')) {
return $this->correlationTrail = null;
}

$trail = Str::random(8);

if ($existingTrail = request()->header('X-Correlation-Trail')) {
return $this->correlationTrail = "{$existingTrail}:{$trail}";
}

return $this->correlationTrail = $trail;
}

public function httpHeaders(): array
{
return array_filter([
'X-Correlation-ID' => $this->correlationId(),
'X-Correlation-Trail' => $this->correlationTrail(),
]);
}

public function initiatorResolver(?Closure $resolver = null): ?Closure
{
if (func_num_args() === 1) {
Expand Down
3 changes: 2 additions & 1 deletion src/Bus/Dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ public function __construct(Container $app, BaseDispatcher $dispatcher)

public function dispatchToQueue($command)
{
if (in_array(WithCorrelationId::class, class_uses_recursive($command))) {
if (in_array(WithCorrelation::class, class_uses_recursive($command))) {
$command->correlationId = Auditor::correlationId();
$command->correlationTrail = Auditor::correlationTrail();
}

return parent::dispatchToQueue($command);
Expand Down
16 changes: 16 additions & 0 deletions src/Bus/WithCorrelation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Butler\Audit\Bus;

use Butler\Audit\Jobs\Middleware\SetCorrelation;

trait WithCorrelation
{
public $correlationId;
public $correlationTrail;

public function middleware()
{
return [new SetCorrelation()];
}
}
15 changes: 0 additions & 15 deletions src/Bus/WithCorrelationId.php

This file was deleted.

1 change: 1 addition & 0 deletions src/Facades/Auditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* @method static void assertNothingLogged()
* @method static void assertLoggedCount(int $count)
* @method static string correlationId(?string $correlationId = null)
* @method static string correlationTrail(?string $correlationTrail = null)
* @method static ?\Closure initiatorResolver(?\Closure $resolver)
*
* @see \Butler\Audit\Auditor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

namespace Butler\Audit\Jobs\Middleware;

use Butler\Audit\Bus\WithCorrelationId;
use Butler\Audit\Bus\WithCorrelation;
use Butler\Audit\Facades\Auditor;

class SetCorrelationId
class SetCorrelation
{
public function handle($job, $next)
{
if (in_array(WithCorrelationId::class, class_uses_recursive($job))) {
if (in_array(WithCorrelation::class, class_uses_recursive($job))) {
Auditor::correlationId($job->correlationId);
Auditor::correlationTrail($job->correlationTrail);
}

return $next($job);
Expand Down
9 changes: 6 additions & 3 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ public function boot()
private function addPendingRequestMacro(): void
{
PendingRequest::macro(
'withCorrelationId',
fn () => $this->withHeaders(['X-Correlation-ID' => Auditor::correlationId()])
'withCorrelation',
fn () => $this->withHeaders(Auditor::httpHeaders())
);
}

Expand Down Expand Up @@ -62,7 +62,10 @@ private function extendBusDispatcher()
public function listenForJobProcessedEvent()
{
if ($this->app->runningInConsole()) {
Queue::after(fn (JobProcessed $event) => Auditor::correlationId(null));
Queue::after(function (JobProcessed $event) {
Auditor::correlationId(null);
Auditor::correlationTrail(null);
});
}
}
}
69 changes: 69 additions & 0 deletions tests/AuditorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,75 @@ public function test_correlationId_can_be_set_manually()
$this->assertEquals('not a uuid', $auditor->correlationId());
}

public function test_correlationTrail_returns_null_if_root_event()
{
// NOTE: If "X-Correlation-ID" header is not set, it is a "root event".
request()->headers->remove('X-Correlation-ID');

$this->assertNull($this->makeAuditor()->correlationTrail());
}

public function test_correlationTrail_appends_to_value_from_http_header()
{
request()->headers->set('X-Correlation-ID', Str::uuid());
request()->headers->set('X-Correlation-Trail', 'aaaaaaaa');

$trails = explode(':', $this->makeAuditor()->correlationTrail());

$this->assertCount(2, $trails);
$this->assertEquals('aaaaaaaa', $trails[0]);
$this->assertEquals(8, strlen($trails[1]));
}

public function test_correlationTrail_returns_random_string_if_http_header_is_not_set()
{
request()->headers->set('X-Correlation-ID', Str::uuid());
request()->headers->remove('X-Correlation-Trail');

$this->assertEquals(8, strlen($this->makeAuditor()->correlationTrail()));
}

public function test_correlationTrail_can_be_resetted()
{
request()->headers->set('X-Correlation-ID', Str::uuid());

$auditor = $this->makeAuditor();

$id1 = $auditor->correlationTrail();
$id2 = $auditor->correlationTrail(null);

$this->assertEquals(8, strlen($id1));
$this->assertEquals(8, strlen($id2));
$this->assertNotEquals($id1, $id2);
}

public function test_correlationTrail_can_be_set_manually()
{
$auditor = $this->makeAuditor();

$this->assertEquals('trail', $auditor->correlationTrail('trail'));
$this->assertEquals('trail', $auditor->correlationTrail());
}

public function test_httpHeaders_without_trail()
{
$headers = $this->makeAuditor()->httpHeaders();

$this->assertCount(1, $headers);
$this->assertTrue(Str::isUuid($headers['X-Correlation-ID']));
}

public function test_httpHeaders_with_trail()
{
$auditor = tap($this->makeAuditor())->correlationTrail('aaaa:bbbb');

$headers = $auditor->httpHeaders();

$this->assertCount(2, $headers);
$this->assertTrue(Str::isUuid($headers['X-Correlation-ID']));
$this->assertEquals('aaaa:bbbb', $headers['X-Correlation-Trail']);
}

public function test_initiatorResolver_can_be_set()
{
$auditor = $this->makeAuditor();
Expand Down
25 changes: 13 additions & 12 deletions tests/DispatcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,32 @@

class DispatcherTest extends AbstractTestCase
{
public function test_it_sets_correlation_id_for_job_using_WithCorrelationId_trait()
public function test_it_sets_correlation_for_job_using_WithCorrelationId_trait()
{
Auditor::fake();
Queue::fake();

Auditor::correlationId('a-correlation-id');
Auditor::correlationId('correlation-id');
Auditor::correlationTrail('correlation-tail');

dispatch(new JobWithCorrelationId());
dispatch(new JobWithCorrelation());

Queue::assertPushed(
fn (JobWithCorrelationId $job) => $job->correlationId === 'a-correlation-id'
);
Queue::assertPushed(fn (JobWithCorrelation $job)
=> $job->correlationId === 'correlation-id'
&& $job->correlationTrail === 'correlation-tail');
}

public function test_it_does_not_set_correlation_id_for_job_not_using_WithCorrelationId_trait()
public function test_it_does_not_set_correlation_for_job_not_using_WithCorrelationId_trait()
{
Auditor::fake();
Queue::fake();

dispatch(new JobWithoutCorrelationId());
dispatch(new JobWithoutCorrelation());

Queue::assertPushed(JobWithoutCorrelationId::class);
Queue::assertPushed(JobWithoutCorrelation::class);

Queue::assertNotPushed(
fn (JobWithoutCorrelationId $job) => property_exists($job, 'correlationId')
);
Queue::assertNotPushed(fn (JobWithoutCorrelation $job)
=> property_exists($job, 'correlationId')
&& property_exists($job, 'correlationTrail'));
}
}
Loading