diff --git a/README.md b/README.md
index 56ea387..2784b3a 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,23 @@ 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!
+## 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:
+
+```php
+// config/barstool.php
+'queue' => [
+ 'enabled' => env('BARSTOOL_QUEUE_ENABLED', false),
+ 'connection' => env('BARSTOOL_QUEUE_CONNECTION'), // null uses default connection
+ 'queue' => env('BARSTOOL_QUEUE_NAME'), // null uses default queue
+],
+```
+
+Or simply set `BARSTOOL_QUEUE_ENABLED=true` in your `.env` file.
+
+When queue support is enabled, recordings are dispatched as jobs instead of being written inline. Each job is **unique** (preventing duplicates) and uses **idempotent writes** (`updateOrCreate`), so recordings are safe even if a job is retried. Failed jobs will automatically retry up to 3 times with a backoff of 5 and 30 seconds.
+
## Testing
diff --git a/config/barstool.php b/config/barstool.php
index af87815..e0b0fe2 100644
--- a/config/barstool.php
+++ b/config/barstool.php
@@ -66,4 +66,15 @@
// SensitiveRequest::class // Exclude ALL headers for this request
// SensitiveConnector::class // Exclude `token` header for this request
],
+
+ /*
+ * Queue configuration for recording.
+ * When enabled, recordings will be dispatched as queued jobs
+ * instead of being written to the database synchronously.
+ */
+ 'queue' => [
+ 'enabled' => env('BARSTOOL_QUEUE_ENABLED', false),
+ 'connection' => env('BARSTOOL_QUEUE_CONNECTION'),
+ 'queue' => env('BARSTOOL_QUEUE_NAME'),
+ ],
];
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 9e3ba0d..a639ee2 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -31,6 +31,7 @@
+
diff --git a/src/Barstool.php b/src/Barstool.php
index a9560f3..2e9ee83 100755
--- a/src/Barstool.php
+++ b/src/Barstool.php
@@ -8,6 +8,8 @@
use Illuminate\Support\Str;
use Saloon\Http\PendingRequest;
use Psr\Http\Message\UriInterface;
+use Saloon\Barstool\Enums\RecordingType;
+use Saloon\Barstool\Jobs\RecordBarstoolJob;
use Saloon\Contracts\Body\BodyRepository;
use Saloon\Repositories\Body\StreamBodyRepository;
use Saloon\Exceptions\Request\FatalRequestException;
@@ -132,10 +134,7 @@ private static function recordRequest(PendingRequest $data): void
$data->headers()->add('X-Barstool-UUID', $uuid);
- $entry = new Models\Barstool;
- $entry->uuid = $uuid;
- $entry->fill([...self::getRequestData($data)]);
- $entry->save();
+ self::persist(RecordingType::REQUEST, self::getRequestData($data), $uuid);
}
private static function recordResponse(Response $data): void
@@ -147,15 +146,12 @@ private static function recordResponse(Response $data): void
return;
}
- $entry = Models\Barstool::query()->firstWhere('uuid', $uuid);
+ $payload = [
+ 'duration' => self::calculateDuration($data),
+ ...self::getResponseData($data),
+ ];
- if ($entry) {
- $entry->fill([
- 'duration' => self::calculateDuration($data),
- ...self::getResponseData($data),
- ]);
- $entry->save();
- }
+ self::persist(RecordingType::RESPONSE, $payload, $uuid);
}
public static function calculateDuration(Response|PendingRequest $data): int
@@ -173,15 +169,36 @@ private static function recordFatal(FatalRequestException $data): void
$pendingRequest = $data->getPendingRequest();
$uuid = $pendingRequest->headers()->get('X-Barstool-UUID');
- $entry = Models\Barstool::query()->firstWhere('uuid', $uuid);
+ $payload = [
+ 'duration' => self::calculateDuration($pendingRequest),
+ ...self::getFatalData($data),
+ ];
+
+ self::persist(RecordingType::FATAL, $payload, $uuid);
+ }
+
+ /**
+ * @param array $payload
+ */
+ private static function persist(RecordingType $type, array $payload, string $uuid): void
+ {
+ if (self::shouldQueue()) {
+ RecordBarstoolJob::dispatch($type, $payload, $uuid)
+ ->onConnection(config('barstool.queue.connection'))
+ ->onQueue(config('barstool.queue.queue'));
- if ($entry) {
- $entry->fill([
- 'duration' => self::calculateDuration($pendingRequest),
- ...self::getFatalData($data),
- ]);
- $entry->save();
+ return;
}
+
+ Models\Barstool::query()->updateOrCreate(
+ ['uuid' => $uuid],
+ $payload,
+ );
+ }
+
+ private static function shouldQueue(): bool
+ {
+ return config('barstool.queue.enabled', false) === true;
}
/**
diff --git a/src/Enums/RecordingType.php b/src/Enums/RecordingType.php
new file mode 100644
index 0000000..1cdbbc3
--- /dev/null
+++ b/src/Enums/RecordingType.php
@@ -0,0 +1,12 @@
+ */
+ public array $backoff = [5, 30];
+
+ public int $uniqueFor = 60;
+
+ /**
+ * @param array $data
+ */
+ public function __construct(
+ public readonly RecordingType $type,
+ public readonly array $data,
+ public readonly string $uuid,
+ ) {}
+
+ public function uniqueId(): string
+ {
+ return "{$this->uuid}-{$this->type->value}";
+ }
+
+ public function handle(): void
+ {
+ Barstool::query()->updateOrCreate(
+ ['uuid' => $this->uuid],
+ $this->data,
+ );
+ }
+}
diff --git a/src/Models/Barstool.php b/src/Models/Barstool.php
index 5f2e34b..ac9b0f5 100644
--- a/src/Models/Barstool.php
+++ b/src/Models/Barstool.php
@@ -22,9 +22,10 @@ class Barstool extends Model
use MassPrunable;
- public const string|null UPDATED_AT = null;
+ public const ?string UPDATED_AT = null;
protected $fillable = [
+ 'uuid',
'connector_class',
'request_class',
'method',
diff --git a/tests/BarstoolTest.php b/tests/BarstoolTest.php
index d04d0ee..c4f8a65 100644
--- a/tests/BarstoolTest.php
+++ b/tests/BarstoolTest.php
@@ -3,10 +3,13 @@
declare(strict_types=1);
use Saloon\Http\Faking\MockClient;
+use Saloon\Barstool\Models\Barstool;
use Saloon\Http\Faking\MockResponse;
+use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Artisan;
-use Saloon\Barstool\Models\Barstool;
+use Saloon\Barstool\Enums\RecordingType;
use Saloon\Http\Connectors\NullConnector;
+use Saloon\Barstool\Jobs\RecordBarstoolJob;
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertDatabaseCount;
@@ -565,3 +568,150 @@
->response_body->toBe(json_encode($responseBody));
});
+
+it('dispatches jobs when queue is enabled with correct payload', 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,
+ headers: ['Content-Type' => 'application/json'],
+ ),
+ ]);
+
+ (new SoloUserRequest)->send();
+
+ Queue::assertPushed(RecordBarstoolJob::class, 2);
+
+ Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
+ return $job->type === RecordingType::REQUEST
+ && $job->data['connector_class'] === NullConnector::class
+ && $job->data['request_class'] === SoloUserRequest::class
+ && $job->data['method'] === 'GET'
+ && $job->data['url'] === 'https://tests.saloon.dev/api/user'
+ && $job->data['successful'] === false;
+ });
+
+ Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
+ return $job->type === RecordingType::RESPONSE
+ && $job->data['response_status'] === 200
+ && $job->data['successful'] === true
+ && $job->data['response_body'] === json_encode(['data' => [['name' => 'John Wayne']]])
+ && array_key_exists('duration', $job->data);
+ });
+
+ assertDatabaseCount('barstools', 0);
+});
+
+it('dispatches jobs on the configured queue connection and name', function () {
+ Queue::fake();
+
+ config()->set('barstool.enabled', true);
+ config()->set('barstool.queue.enabled', true);
+ config()->set('barstool.queue.connection', 'redis');
+ config()->set('barstool.queue.queue', 'barstool-recordings');
+
+ 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->connection === 'redis' && $job->queue === 'barstool-recordings';
+ });
+});
+
+it('processes queued jobs and creates database records with correct data', function () {
+ config()->set('barstool.enabled', true);
+ config()->set('barstool.queue.enabled', true);
+ config()->set('queue.default', 'sync');
+
+ MockClient::global([
+ SoloUserRequest::class => MockResponse::make(
+ body: ['data' => [['name' => 'John Wayne']]],
+ status: 200,
+ headers: ['Content-Type' => 'application/json'],
+ ),
+ ]);
+
+ $response = (new SoloUserRequest)->send();
+
+ assertDatabaseCount('barstools', 1);
+
+ $uuid = $response->getPsrRequest()->getHeader('X-Barstool-UUID')[0];
+ $barstool = Barstool::where('uuid', $uuid)->sole();
+
+ expect($barstool)
+ ->connector_class->toBe(NullConnector::class)
+ ->request_class->toBe(SoloUserRequest::class)
+ ->method->toBe('GET')
+ ->url->toBe('https://tests.saloon.dev/api/user')
+ ->response_status->toBe(200)
+ ->successful->toBeTrue()
+ ->response_body->toBe(json_encode(['data' => [['name' => 'John Wayne']]]))
+ ->duration->not->toBeNull()
+ ->request_headers->toBeArray()
+ ->response_headers->toBeArray();
+});
+
+it('dispatches a fatal job when queue is enabled with correct payload', function () {
+ Queue::fake();
+
+ config()->set('barstool.enabled', true);
+ config()->set('barstool.queue.enabled', true);
+
+ MockClient::global([
+ SoloUserRequest::class => MockResponse::make(
+ body: ['error' => 'Something went wrong'],
+ status: 500,
+ )->throw(fn ($pendingRequest) => new FatalRequestException(new Exception('Fatal error'), $pendingRequest)),
+ ]);
+
+ try {
+ (new SoloUserRequest)->send();
+ } catch (FatalRequestException) {
+ // Expected
+ }
+
+ Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
+ return $job->type === RecordingType::REQUEST
+ && $job->data['connector_class'] === NullConnector::class
+ && $job->data['request_class'] === SoloUserRequest::class;
+ });
+
+ Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
+ return $job->type === RecordingType::FATAL
+ && $job->data['fatal_error'] === 'Fatal error'
+ && $job->data['successful'] === false
+ && $job->data['response_body'] === null
+ && array_key_exists('duration', $job->data);
+ });
+});
+
+it('does not dispatch jobs when queue is disabled', function () {
+ Queue::fake();
+
+ config()->set('barstool.enabled', true);
+ config()->set('barstool.queue.enabled', false);
+
+ MockClient::global([
+ SoloUserRequest::class => MockResponse::make(
+ body: ['data' => [['name' => 'John Wayne']]],
+ status: 200,
+ ),
+ ]);
+
+ (new SoloUserRequest)->send();
+
+ Queue::assertNothingPushed();
+
+ assertDatabaseCount('barstools', 1);
+});