diff --git a/README.md b/README.md index 2784b3a..3690b2a 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/database/migrations/add_context_to_barstools_table.php.stub b/database/migrations/add_context_to_barstools_table.php.stub new file mode 100644 index 0000000..9dbcb7c --- /dev/null +++ b/database/migrations/add_context_to_barstools_table.php.stub @@ -0,0 +1,33 @@ +getConnection()); + + if ($schema->hasColumn('barstools', 'context')) { + return; + } + + $schema->table('barstools', function (Blueprint $table) { + $table->json('context')->nullable(); + }); + } +}; diff --git a/database/migrations/create_barstools_table.php.stub b/database/migrations/create_barstools_table.php.stub index e417a2b..01efe03 100644 --- a/database/migrations/create_barstools_table.php.stub +++ b/database/migrations/create_barstools_table.php.stub @@ -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(); }); } }; diff --git a/src/Barstool.php b/src/Barstool.php index 8a42b59..7dc930a 100755 --- a/src/Barstool.php +++ b/src/Barstool.php @@ -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; @@ -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 $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 + */ + public static function getContext(): array + { + /** @var array $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) { @@ -56,7 +93,8 @@ public static function record(PendingRequest|Response|FatalRequestException $dat * url: string, * request_headers: array|null, * request_body: BodyRepository|string|null, - * successful: false + * successful: false, + * context?: array * } */ private static function getRequestData(PendingRequest $request): array @@ -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, @@ -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; } /** diff --git a/src/BarstoolServiceProvider.php b/src/BarstoolServiceProvider.php index 29db6b6..c80e4f1 100644 --- a/src/BarstoolServiceProvider.php +++ b/src/BarstoolServiceProvider.php @@ -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 diff --git a/src/Models/Barstool.php b/src/Models/Barstool.php index ac9b0f5..a0eb3b3 100644 --- a/src/Models/Barstool.php +++ b/src/Models/Barstool.php @@ -14,6 +14,7 @@ /** * @property string $uuid * @property CarbonInterface $created_at + * @property array|null $context */ class Barstool extends Model { @@ -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', ]; /** diff --git a/tests/BarstoolTest.php b/tests/BarstoolTest.php index f4fe21a..f255d06 100644 --- a/tests/BarstoolTest.php +++ b/tests/BarstoolTest.php @@ -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); +});