Skip to content
Open
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions src/Http/Middleware/PulseRequestMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Saloon\Laravel\Http\Middleware;

use Saloon\Laravel\Saloon;
use Saloon\Http\PendingRequest;
use Saloon\Contracts\RequestMiddleware;

class PulseRequestMiddleware implements RequestMiddleware
{
public function __invoke(PendingRequest $pendingRequest): void
{
// Check if Pulse is installed

if (! class_exists('Laravel\Pulse\Facades\Pulse')) {
return;
}

// Record start time for duration calculation
$requestId = spl_object_id($pendingRequest);

Saloon::$pulseStartTimes[$requestId] = microtime(true);
}
}
136 changes: 136 additions & 0 deletions src/Http/Middleware/PulseResponseMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

namespace Saloon\Laravel\Http\Middleware;

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
{
public function __invoke(Response $response): void
{
// Check if Pulse is installed

if (! class_exists('Laravel\Pulse\Facades\Pulse')) {
return;
}

$pendingRequest = $response->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<string, int> $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<string, string> $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;
}
}
7 changes: 7 additions & 0 deletions src/Saloon.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ class Saloon
*/
public static array $telescopeStartTimes = [];

/**
* Track start time for Pulse duration calculation
*
* @var array<int, float>
*/
public static array $pulseStartTimes = [];

/**
* Start mocking!
*
Expand Down
7 changes: 6 additions & 1 deletion src/SaloonServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand All @@ -82,6 +86,7 @@ public function boot(): void
$this->app->terminating(function () {
Saloon::$registeredSenders = [];
Saloon::$telescopeStartTimes = [];
Saloon::$pulseStartTimes = [];
});
}

Expand Down
180 changes: 180 additions & 0 deletions tests/Feature/PulseMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

declare(strict_types=1);

use Saloon\Laravel\Saloon;
use Saloon\Http\PendingRequest;
use Saloon\Laravel\Tests\Fixtures\Requests\UserRequest;
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();
$request = new UserRequest();
$pendingRequest = new PendingRequest($connector, $request);

$middleware = new PulseRequestMiddleware();

// Should not throw any exceptions even when Pulse is not available
expect(function () use ($middleware, $pendingRequest) {
$middleware->__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');
});
Loading