From 01c86a0c1bbea91a64f81d3cf757da054bcec574 Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Sat, 27 Dec 2025 17:46:59 +0000 Subject: [PATCH 1/2] add laravel pulse integration --- composer.json | 1 + .../Middleware/PulseRequestMiddleware.php | 27 +++ .../Middleware/PulseResponseMiddleware.php | 133 +++++++++++++ src/Saloon.php | 7 + src/SaloonServiceProvider.php | 7 +- tests/Feature/PulseMiddlewareTest.php | 181 ++++++++++++++++++ tests/Pest.php | 1 + 7 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 src/Http/Middleware/PulseRequestMiddleware.php create mode 100644 src/Http/Middleware/PulseResponseMiddleware.php create mode 100644 tests/Feature/PulseMiddlewareTest.php diff --git a/composer.json b/composer.json index 9966171..d2853ca 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.48", + "laravel/pulse": "^1.4", "laravel/telescope": "^5.16", "orchestra/testbench": "^9.15 || ^10.7", "pestphp/pest": "^3.0|^4.0", diff --git a/src/Http/Middleware/PulseRequestMiddleware.php b/src/Http/Middleware/PulseRequestMiddleware.php new file mode 100644 index 0000000..31547da --- /dev/null +++ b/src/Http/Middleware/PulseRequestMiddleware.php @@ -0,0 +1,27 @@ +getPendingRequest(); + + $requestId = spl_object_id($pendingRequest); + $startTime = Saloon::$pulseStartTimes[$requestId] ?? null; + + // Calculate duration in milliseconds + $duration = $startTime !== null ? (int)((microtime(true) - $startTime) * 1000) : null; + + // Clean up start time + unset(Saloon::$pulseStartTimes[$requestId]); + + // Record to Pulse + if ($duration !== null) { + $this->recordToPulse($pendingRequest, $duration); + } + } + + /** + * Record the request to Pulse + */ + protected function recordToPulse(PendingRequest $pendingRequest, int $duration): void + { + $psrRequest = $pendingRequest->createPsrRequest(); + $method = $psrRequest->getMethod(); + $uri = (string) $psrRequest->getUri(); + + // Get the recorder configuration + $recorderClass = \Laravel\Pulse\Recorders\SlowOutgoingRequests::class; + $config = Config::get('pulse.recorders.'.$recorderClass, []); + + // Check if the recorder is enabled + if (! ($config['enabled'] ?? true)) { + return; + } + + // Check sampling (using Lottery like Pulse does) + $sampleRate = $config['sample_rate'] ?? 1; + if (! Lottery::odds($sampleRate)->choose()) { + return; + } + + // Check ignore patterns (using same logic as Pulse Ignores trait) + $ignore = $config['ignore'] ?? []; + foreach ($ignore as $pattern) { + if (preg_match($pattern, $uri)) { + return; + } + } + + // Check threshold (using same logic as Pulse Thresholds trait) + $threshold = $this->getThreshold($uri, $config['threshold'] ?? 100); + if ($duration < $threshold) { + return; + } + + // Group the URI (using same logic as Pulse Groups trait) + $groupedUri = $this->groupUri($uri, $config['groups'] ?? []); + + $timestamp = CarbonImmutable::now()->getTimestamp(); + + // Record to Pulse using the same format as SlowOutgoingRequests recorder + \Laravel\Pulse\Facades\Pulse::record( + type: 'slow_outgoing_request', + key: json_encode([$method, $groupedUri], flags: JSON_THROW_ON_ERROR), + value: $duration, + timestamp: $timestamp, + )->max()->count(); + } + + /** + * Get the threshold for the given URI (matching Pulse Thresholds trait logic) + */ + protected function getThreshold(string $uri, int|array $threshold): int + { + if (! is_array($threshold)) { + return $threshold; + } + + // Check for pattern matches + foreach ($threshold as $pattern => $value) { + if ($pattern === 'default') { + continue; + } + + if (preg_match($pattern, $uri) === 1) { + return $value; + } + } + + return $threshold['default'] ?? 100; + } + + /** + * Group the URI according to configured groups (matching Pulse Groups trait logic) + */ + protected function groupUri(string $uri, array $groups): string + { + foreach ($groups as $pattern => $replacement) { + $group = preg_replace($pattern, $replacement, $uri, count: $count); + + if ($count > 0 && $group !== null) { + return $group; + } + } + + return $uri; + } +} + diff --git a/src/Saloon.php b/src/Saloon.php index 44bb4ae..b532666 100644 --- a/src/Saloon.php +++ b/src/Saloon.php @@ -43,6 +43,13 @@ class Saloon */ public static array $telescopeStartTimes = []; + /** + * Track start time for Pulse duration calculation + * + * @var array + */ + public static array $pulseStartTimes = []; + /** * Start mocking! * diff --git a/src/SaloonServiceProvider.php b/src/SaloonServiceProvider.php index 8ee20fd..38dcd99 100644 --- a/src/SaloonServiceProvider.php +++ b/src/SaloonServiceProvider.php @@ -21,6 +21,8 @@ use Saloon\Laravel\Http\Middleware\SendResponseEvent; use Saloon\Laravel\Console\Commands\MakeAuthenticator; use Saloon\Laravel\Http\Middleware\NightwatchMiddleware; +use Saloon\Laravel\Http\Middleware\PulseRequestMiddleware; +use Saloon\Laravel\Http\Middleware\PulseResponseMiddleware; use Saloon\Laravel\Http\Middleware\TelescopeRequestMiddleware; use Saloon\Laravel\Http\Middleware\TelescopeResponseMiddleware; @@ -65,10 +67,12 @@ public function boot(): void ->onRequest(new MockMiddleware, 'laravelMock') ->onRequest(new NightwatchMiddleware, 'laravelNightwatch') ->onRequest(new TelescopeRequestMiddleware, 'laravelTelescopeRequest') + ->onRequest(new PulseRequestMiddleware, 'laravelPulseRequest') ->onRequest(new SendRequestEvent, 'laravelSendRequestEvent', PipeOrder::LAST) ->onResponse(new RecordResponse, 'laravelRecordResponse', PipeOrder::FIRST) ->onResponse(new SendResponseEvent, 'laravelSendResponseEvent', PipeOrder::FIRST) - ->onResponse(new TelescopeResponseMiddleware, 'laravelTelescopeResponse'); + ->onResponse(new TelescopeResponseMiddleware, 'laravelTelescopeResponse') + ->onResponse(new PulseResponseMiddleware, 'laravelPulseResponse'); Saloon::$registeredDefaults = true; } @@ -82,6 +86,7 @@ public function boot(): void $this->app->terminating(function () { Saloon::$registeredSenders = []; Saloon::$telescopeStartTimes = []; + Saloon::$pulseStartTimes = []; }); } diff --git a/tests/Feature/PulseMiddlewareTest.php b/tests/Feature/PulseMiddlewareTest.php new file mode 100644 index 0000000..973a500 --- /dev/null +++ b/tests/Feature/PulseMiddlewareTest.php @@ -0,0 +1,181 @@ +__invoke($pendingRequest); + })->not->toThrow(Exception::class); +}); + +test('pulse middleware works with any sender', function () { + $connector = TestConnector::make(); + $request = new UserRequest(); + $pendingRequest = new PendingRequest($connector, $request); + + $middleware = new PulseRequestMiddleware(); + + // Should handle gracefully for any sender type + expect(function () use ($middleware, $pendingRequest) { + $middleware->__invoke($pendingRequest); + })->not->toThrow(Exception::class); +}); + +test('pulse middleware tracks start time', function () { + $connector = TestConnector::make(); + $request = new UserRequest(); + + expect(Saloon::$pulseStartTimes)->toHaveCount(0); + + $pendingRequest = new PendingRequest($connector, $request); + + $middleware = new PulseRequestMiddleware(); + $middleware->__invoke($pendingRequest); + + expect(Saloon::$pulseStartTimes)->toHaveCount(1); +}); + +test('pulse middleware calculates duration', function () { + $connector = TestConnector::make(); + $request = new UserRequest(); + $pendingRequest = new PendingRequest($connector, $request); + + // Create a mock response + $psrRequest = $pendingRequest->createPsrRequest(); + $psrResponse = new \GuzzleHttp\Psr7\Response(200, [], '{"name":"Test"}'); + $response = \Saloon\Http\Response::fromPsrResponse($psrResponse, $pendingRequest, $psrRequest); + + $middleware = new PulseRequestMiddleware(); + + // Handle sending event to set start time + $middleware->__invoke($pendingRequest); + + // Small delay + usleep(1000); // 1ms + + // Handle sent event + expect(function () use ($response) { + (new PulseResponseMiddleware)->__invoke($response); + })->not->toThrow(Exception::class); +}); + +test('pulse middleware handles response when pulse is not available', function () { + $connector = TestConnector::make(); + $request = new UserRequest(); + $pendingRequest = new PendingRequest($connector, $request); + + // Create a mock response + $psrRequest = $pendingRequest->createPsrRequest(); + $psrResponse = new \GuzzleHttp\Psr7\Response(200, [], '{"name":"Test"}'); + $response = \Saloon\Http\Response::fromPsrResponse($psrResponse, $pendingRequest, $psrRequest); + + $middleware = new PulseResponseMiddleware(); + + // Should not throw any exceptions even when Pulse is not available + expect(function () use ($middleware, $response) { + $middleware->__invoke($response); + })->not->toThrow(Exception::class); +}); + +test('pulse middleware gets threshold for simple integer threshold', function () { + $middleware = new PulseResponseMiddleware(); + + $reflection = new ReflectionClass($middleware); + $method = $reflection->getMethod('getThreshold'); + + $uri = 'https://example.com/api/users'; + $threshold = 100; + $result = $method->invoke($middleware, $uri, $threshold); + + expect($result)->toBe(100); +}); + +test('pulse middleware gets threshold for array with pattern match', function () { + $middleware = new PulseResponseMiddleware(); + + $reflection = new ReflectionClass($middleware); + $method = $reflection->getMethod('getThreshold'); + + $uri = 'https://example.com/api/users/123'; + $threshold = [ + '/api\/users\/\d+/' => 200, + 'default' => 100, + ]; + $result = $method->invoke($middleware, $uri, $threshold); + + expect($result)->toBe(200); +}); + +test('pulse middleware gets threshold for array with default fallback', function () { + $middleware = new PulseResponseMiddleware(); + + $reflection = new ReflectionClass($middleware); + $method = $reflection->getMethod('getThreshold'); + + $uri = 'https://example.com/api/posts'; + $threshold = [ + '/api\/users\/\d+/' => 200, + 'default' => 150, + ]; + $result = $method->invoke($middleware, $uri, $threshold); + + expect($result)->toBe(150); +}); + +test('pulse middleware groups uri with pattern match', function () { + $middleware = new PulseResponseMiddleware(); + + $reflection = new ReflectionClass($middleware); + $method = $reflection->getMethod('groupUri'); + + $uri = 'https://example.com/api/users/123'; + $groups = [ + '/\/users\/\d+/' => '/users/{id}', + ]; + $result = $method->invoke($middleware, $uri, $groups); + + expect($result)->toBe('https://example.com/api/users/{id}'); +}); + +test('pulse middleware groups uri without match returns original', function () { + $middleware = new PulseResponseMiddleware(); + + $reflection = new ReflectionClass($middleware); + $method = $reflection->getMethod('groupUri'); + + $uri = 'https://example.com/api/posts'; + $groups = [ + '/\/users\/\d+/' => '/users/{id}', + ]; + $result = $method->invoke($middleware, $uri, $groups); + + expect($result)->toBe('https://example.com/api/posts'); +}); + +test('pulse middleware groups uri with empty groups returns original', function () { + $middleware = new PulseResponseMiddleware(); + + $reflection = new ReflectionClass($middleware); + $method = $reflection->getMethod('groupUri'); + + $uri = 'https://example.com/api/users/123'; + $groups = []; + $result = $method->invoke($middleware, $uri, $groups); + + expect($result)->toBe('https://example.com/api/users/123'); +}); + diff --git a/tests/Pest.php b/tests/Pest.php index a10b48a..72b4aad 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -21,6 +21,7 @@ pest()->afterEach(function () { Saloon::$registeredSenders = []; Saloon::$telescopeStartTimes = []; + Saloon::$pulseStartTimes = []; }); /* From c615cfe42fc6cf5a50e917247c446e27d9b9168c Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Sat, 27 Dec 2025 17:58:21 +0000 Subject: [PATCH 2/2] run cs fixer and phpstan --- src/Http/Middleware/PulseRequestMiddleware.php | 1 - src/Http/Middleware/PulseResponseMiddleware.php | 11 +++++++---- tests/Feature/PulseMiddlewareTest.php | 3 +-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Http/Middleware/PulseRequestMiddleware.php b/src/Http/Middleware/PulseRequestMiddleware.php index 31547da..2e5d906 100644 --- a/src/Http/Middleware/PulseRequestMiddleware.php +++ b/src/Http/Middleware/PulseRequestMiddleware.php @@ -24,4 +24,3 @@ public function __invoke(PendingRequest $pendingRequest): void Saloon::$pulseStartTimes[$requestId] = microtime(true); } } - diff --git a/src/Http/Middleware/PulseResponseMiddleware.php b/src/Http/Middleware/PulseResponseMiddleware.php index 6d9d8c1..7958d01 100644 --- a/src/Http/Middleware/PulseResponseMiddleware.php +++ b/src/Http/Middleware/PulseResponseMiddleware.php @@ -4,12 +4,12 @@ namespace Saloon\Laravel\Http\Middleware; -use Carbon\CarbonImmutable; -use Illuminate\Support\Facades\Config; -use Illuminate\Support\Lottery; use Saloon\Http\Response; use Saloon\Laravel\Saloon; +use Carbon\CarbonImmutable; +use Illuminate\Support\Lottery; use Saloon\Http\PendingRequest; +use Illuminate\Support\Facades\Config; use Saloon\Contracts\ResponseMiddleware; class PulseResponseMiddleware implements ResponseMiddleware @@ -93,6 +93,8 @@ protected function recordToPulse(PendingRequest $pendingRequest, int $duration): /** * Get the threshold for the given URI (matching Pulse Thresholds trait logic) + * + * @param int|array $threshold */ protected function getThreshold(string $uri, int|array $threshold): int { @@ -116,6 +118,8 @@ protected function getThreshold(string $uri, int|array $threshold): int /** * Group the URI according to configured groups (matching Pulse Groups trait logic) + * + * @param array $groups */ protected function groupUri(string $uri, array $groups): string { @@ -130,4 +134,3 @@ protected function groupUri(string $uri, array $groups): string return $uri; } } - diff --git a/tests/Feature/PulseMiddlewareTest.php b/tests/Feature/PulseMiddlewareTest.php index 973a500..1a0b4f7 100644 --- a/tests/Feature/PulseMiddlewareTest.php +++ b/tests/Feature/PulseMiddlewareTest.php @@ -5,9 +5,9 @@ use Saloon\Laravel\Saloon; use Saloon\Http\PendingRequest; use Saloon\Laravel\Tests\Fixtures\Requests\UserRequest; -use Saloon\Laravel\Tests\Fixtures\Connectors\TestConnector; use Saloon\Laravel\Http\Middleware\PulseRequestMiddleware; use Saloon\Laravel\Http\Middleware\PulseResponseMiddleware; +use Saloon\Laravel\Tests\Fixtures\Connectors\TestConnector; test('pulse middleware handles sending event without errors when pulse is not available', function () { $connector = TestConnector::make(); @@ -178,4 +178,3 @@ expect($result)->toBe('https://example.com/api/users/123'); }); -