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..2e5d906 --- /dev/null +++ b/src/Http/Middleware/PulseRequestMiddleware.php @@ -0,0 +1,26 @@ +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) + * + * @param int|array $threshold + */ + 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) + * + * @param array $groups + */ + 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..1a0b4f7 --- /dev/null +++ b/tests/Feature/PulseMiddlewareTest.php @@ -0,0 +1,180 @@ +__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 = []; }); /*