Skip to content
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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,55 @@ The logging will even log fatal errors caused by your saloon requests so you can
> [!TIP]
> We will be adding more features soon, so keep an eye out for updates!

## Adding context to recordings

Sometimes the request and response alone don't tell the whole story. You can attach your own context to recordings — the current user, tenant, job name, anything you like — and it will be stored in the `context` column of the `barstools` table as JSON:

```php
use Saloon\Barstool\Barstool;

Barstool::context([
'user_id' => auth()->id(),
'tenant_id' => $tenant->id,
]);

// Or add a single key:
Barstool::addContext('job', 'user-sync');
```

Once set, the context is stored against every request Barstool records until the end of the current request or job. Calling `context()` again merges the new keys in (overwriting any that already exist), and you can clear everything with `Barstool::flushContext()`.

Under the hood this uses [Laravel Context](https://laravel.com/docs/context) hidden data, which means:

- Context added in a controller is carried into queued jobs automatically, so requests sent from inside a job still record it.
- It is reset between requests and jobs by the framework, so nothing leaks across tenants or users.
- It stays out of your application's log context.

> [!IMPORTANT]
> If you are upgrading from an earlier version of Barstool, publish and run the migrations again to add the new `context` column:
> ```bash
> php artisan vendor:publish --tag="barstool-migrations"
> php artisan migrate
> ```
> Barstool only touches the `context` column when you actually set context, so upgrading the package without running the migration is safe until you start using this feature.

## Correlating Barstool records with your own models

If you want a row in one of your own tables to point at a Barstool recording (rather than storing extra data on the recording itself), you can read the recording's UUID straight off the sent request. Barstool adds an `X-Barstool-UUID` header to every request it records:

```php
$response = $connector->send($request);

$barstoolUuid = $response->getPendingRequest()->headers()->get('X-Barstool-UUID');

if ($response->failed()) {
UserSyncLog::create([
'user_id' => auth()->id(),
'barstool_uuid' => $barstoolUuid,
]);
}
```

## Queue Support

By default, Barstool writes recordings to the database synchronously. If you'd like to offload this to a queue, you can enable it in the config:
Expand Down
33 changes: 33 additions & 0 deletions database/migrations/add_context_to_barstools_table.php.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{

/**
* Get the migration connection name.
*/
public function getConnection(): string|null
{
return config('barstool.connection') ?? config('barstool.database_connection');
}

/**
* Run the migrations.
*/
public function up()
{
$schema = Schema::connection($this->getConnection());

if ($schema->hasColumn('barstools', 'context')) {
return;
}

$schema->table('barstools', function (Blueprint $table) {
$table->json('context')->nullable();
});
}
};
1 change: 1 addition & 0 deletions database/migrations/create_barstools_table.php.stub
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ return new class extends Migration
$table->boolean('successful');
$table->float('duration')->nullable();
$table->longText('fatal_error')->nullable();
$table->json('context')->nullable();
});
}
};
52 changes: 50 additions & 2 deletions src/Barstool.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Support\Str;
use Saloon\Http\PendingRequest;
use Psr\Http\Message\UriInterface;
use Illuminate\Support\Facades\Context;
use Saloon\Barstool\Enums\RecordingType;
use Saloon\Contracts\Body\BodyRepository;
use Saloon\Barstool\Jobs\RecordBarstoolJob;
Expand All @@ -17,6 +18,42 @@

class Barstool
{
private const string CONTEXT_KEY = 'barstool:context';

/**
* Merge the given key/value pairs into the Barstool context.
*
* Context is stored as hidden data on Laravel's Context, so it is carried
* into queued jobs but never leaks into the application's log context.
*
* @param array<string, mixed> $context
*/
public static function context(array $context): void
{
Context::addHidden(self::CONTEXT_KEY, [...self::getContext(), ...$context]);
}

public static function addContext(string $key, mixed $value): void
{
self::context([$key => $value]);
}

/**
* @return array<string, mixed>
*/
public static function getContext(): array
{
/** @var array<string, mixed> $context */
$context = Context::getHidden(self::CONTEXT_KEY, []);

return $context;
}

public static function flushContext(): void
{
Context::forgetHidden(self::CONTEXT_KEY);
}

public static function shouldRecord(PendingRequest|Response|FatalRequestException $data): bool
{
if (config('barstool.enabled') !== true) {
Expand Down Expand Up @@ -56,7 +93,8 @@ public static function record(PendingRequest|Response|FatalRequestException $dat
* url: string,
* request_headers: array<string, string>|null,
* request_body: BodyRepository|string|null,
* successful: false
* successful: false,
* context?: array<string, mixed>
* }
*/
private static function getRequestData(PendingRequest $request): array
Expand All @@ -69,7 +107,7 @@ private static function getRequestData(PendingRequest $request): array
default => $body,
};

return [
$data = [
'connector_class' => get_class($request->getConnector()),
'request_class' => get_class($request->getRequest()),
'method' => $request->getMethod()->value,
Expand All @@ -78,6 +116,16 @@ private static function getRequestData(PendingRequest $request): array
'request_body' => $body,
'successful' => false,
];

// Only reference the context column when there is context to store, so upgraded
// installs that have not run the add-context migration are unaffected.
$context = self::getContext();

if ($context !== []) {
$data['context'] = $context;
}

return $data;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/BarstoolServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function configurePackage(Package $package): void
->name('barstool')
->hasConfigFile()
->hasViews()
->hasMigration('create_barstools_table');
->hasMigrations(['create_barstools_table', 'add_context_to_barstools_table']);
}

public function packageRegistered(): void
Expand Down
3 changes: 3 additions & 0 deletions src/Models/Barstool.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
/**
* @property string $uuid
* @property CarbonInterface $created_at
* @property array<string, mixed>|null $context
*/
class Barstool extends Model
{
Expand All @@ -38,12 +39,14 @@ class Barstool extends Model
'successful',
'duration',
'fatal_error',
'context',
];

protected $casts = [
'request_headers' => 'array',
'response_headers' => 'array',
'successful' => 'boolean',
'context' => 'array',
];

/**
Expand Down
125 changes: 125 additions & 0 deletions tests/BarstoolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -770,3 +770,128 @@

assertDatabaseCount('barstools', 1);
});

it('records context alongside the request', function () {
config()->set('barstool.enabled', true);

MockClient::global([
SoloUserRequest::class => MockResponse::make(
body: ['data' => [['name' => 'John Wayne']]],
status: 200,
),
]);

BarstoolRecorder::context([
'user_id' => 5,
'tenant_id' => 9,
]);

$response = (new SoloUserRequest)->send();

$uuid = $response->getPendingRequest()->headers()->get('X-Barstool-UUID');
$barstool = Barstool::where('uuid', $uuid)->sole();

expect($barstool->context)->toBe([
'user_id' => 5,
'tenant_id' => 9,
]);
});

it('merges context and overwrites existing keys', function () {
BarstoolRecorder::context(['user_id' => 5, 'tenant_id' => 9]);
BarstoolRecorder::addContext('user_id', 10);
BarstoolRecorder::context(['job' => 'user-sync']);

expect(BarstoolRecorder::getContext())->toBe([
'user_id' => 10,
'tenant_id' => 9,
'job' => 'user-sync',
]);

BarstoolRecorder::flushContext();

expect(BarstoolRecorder::getContext())->toBe([]);
});

it('stores no context when none has been set', function () {
config()->set('barstool.enabled', true);

MockClient::global([
SoloUserRequest::class => MockResponse::make(
body: ['data' => [['name' => 'John Wayne']]],
status: 200,
),
]);

(new SoloUserRequest)->send();

assertDatabaseCount('barstools', 1);
expect(Barstool::sole()->context)->toBeNull();
});

it('omits the context key from queued payloads when no context has been set', function () {
Queue::fake();

config()->set('barstool.enabled', true);
config()->set('barstool.queue.enabled', true);

MockClient::global([
SoloUserRequest::class => MockResponse::make(
body: ['data' => [['name' => 'John Wayne']]],
status: 200,
),
]);

(new SoloUserRequest)->send();

Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
return $job->type === RecordingType::REQUEST
&& array_key_exists('context', $job->data) === false;
});
});

it('includes context in queued payloads', function () {
Queue::fake();

config()->set('barstool.enabled', true);
config()->set('barstool.queue.enabled', true);

MockClient::global([
SoloUserRequest::class => MockResponse::make(
body: ['data' => [['name' => 'John Wayne']]],
status: 200,
),
]);

BarstoolRecorder::addContext('user_id', 5);

(new SoloUserRequest)->send();

Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
return $job->type === RecordingType::REQUEST
&& $job->data['context'] === ['user_id' => 5];
});
});

it('exposes the recording uuid on the sent request for correlation', function () {
config()->set('barstool.enabled', true);

MockClient::global([
SoloUserRequest::class => MockResponse::make(
body: ['data' => [['name' => 'John Wayne']]],
status: 200,
),
]);

$response = (new SoloUserRequest)->send();

// The documented pattern for linking your own records to a barstool row:
// read the generated UUID back off the sent request.
$uuid = $response->getPendingRequest()->headers()->get('X-Barstool-UUID');

expect($uuid)->toBeString()->not->toBeEmpty();

$barstool = Barstool::where('uuid', $uuid)->sole();

expect($barstool->uuid)->toBe($uuid);
});