Skip to content

Commit 19c7ab4

Browse files
JonPurvisSammyjo20
andauthored
Laravel Telescope Integration (#70)
* add laravel telescope integration * Refactored to avoid hooking into events * Fixed tests --------- Co-authored-by: Sammyjo20 <29132017+Sammyjo20@users.noreply.github.com>
1 parent 017ecb8 commit 19c7ab4

File tree

7 files changed

+273
-1
lines changed

7 files changed

+273
-1
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"require-dev": {
2929
"friendsofphp/php-cs-fixer": "^3.48",
30+
"laravel/telescope": "^5.16",
3031
"orchestra/testbench": "^9.15 || ^10.7",
3132
"pestphp/pest": "^3.0|^4.0",
3233
"phpstan/phpstan": "^1.10.57|^2.0.2"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Saloon\Laravel\Http\Middleware;
6+
7+
use Saloon\Laravel\Saloon;
8+
use Saloon\Http\PendingRequest;
9+
use Saloon\Contracts\RequestMiddleware;
10+
11+
class TelescopeRequestMiddleware implements RequestMiddleware
12+
{
13+
public function __invoke(PendingRequest $pendingRequest): void
14+
{
15+
// Check if Telescope is installed
16+
17+
if (! class_exists('Laravel\Telescope\Telescope')) {
18+
return;
19+
}
20+
21+
// Record start time for duration calculation
22+
$requestId = spl_object_id($pendingRequest);
23+
24+
Saloon::$telescopeStartTimes[$requestId] = microtime(true);
25+
}
26+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Saloon\Laravel\Http\Middleware;
6+
7+
use Saloon\Http\Response;
8+
use Saloon\Laravel\Saloon;
9+
use Saloon\Http\PendingRequest;
10+
use Saloon\Contracts\ResponseMiddleware;
11+
12+
class TelescopeResponseMiddleware implements ResponseMiddleware
13+
{
14+
public function __invoke(Response $response): void
15+
{
16+
// Check if Telescope is installed
17+
18+
if (! class_exists('Laravel\Telescope\Telescope')) {
19+
return;
20+
}
21+
22+
$pendingRequest = $response->getPendingRequest();
23+
24+
$requestId = spl_object_id($pendingRequest);
25+
$startTime = Saloon::$telescopeStartTimes[$requestId] ?? null;
26+
27+
// Calculate duration
28+
$duration = $startTime !== null ? (int)((microtime(true) - $startTime) * 1000) : null;
29+
30+
// Clean up start time
31+
unset(Saloon::$telescopeStartTimes[$requestId]);
32+
33+
// Record to Telescope
34+
35+
$this->recordToTelescope($pendingRequest, $response, $duration);
36+
}
37+
38+
/**
39+
* Record the request to Telescope
40+
*/
41+
protected function recordToTelescope(PendingRequest $pendingRequest, Response $response, ?int $duration): void
42+
{
43+
if (! \Laravel\Telescope\Telescope::isRecording()) {
44+
return;
45+
}
46+
47+
$psrRequest = $pendingRequest->createPsrRequest();
48+
$psrResponse = $response->getPsrResponse();
49+
50+
// Format request data
51+
$requestData = [
52+
'method' => $psrRequest->getMethod(),
53+
'url' => (string)$psrRequest->getUri(),
54+
'headers' => $psrRequest->getHeaders(),
55+
'body' => self::formatBody((string)$psrRequest->getBody(), $psrRequest->getHeaderLine('Content-Type')),
56+
];
57+
58+
// Format response data
59+
$responseData = [
60+
'status' => $psrResponse->getStatusCode(),
61+
'headers' => $psrResponse->getHeaders(),
62+
'body' => self::formatBody((string)$psrResponse->getBody(), $psrResponse->getHeaderLine('Content-Type')),
63+
];
64+
65+
// Record to Telescope using IncomingEntry
66+
$entry = \Laravel\Telescope\IncomingEntry::make([
67+
'method' => $requestData['method'],
68+
'uri' => $requestData['url'],
69+
'headers' => $requestData['headers'],
70+
'payload' => $requestData['body'],
71+
'response_status' => $responseData['status'],
72+
'response_headers' => $responseData['headers'],
73+
'response' => $responseData['body'],
74+
'duration' => $duration,
75+
])->tags(['saloon']);
76+
77+
\Laravel\Telescope\Telescope::recordClientRequest($entry);
78+
}
79+
80+
/**
81+
* Format body for display
82+
*
83+
* @return array<string, mixed>|string
84+
*/
85+
protected static function formatBody(string $body, string $contentType): array|string
86+
{
87+
if (empty($body)) {
88+
return '';
89+
}
90+
91+
// Try to decode JSON
92+
if (str_contains($contentType, 'application/json')) {
93+
$decoded = json_decode($body, true);
94+
if (json_last_error() === JSON_ERROR_NONE) {
95+
return $decoded;
96+
}
97+
}
98+
99+
// Try to decode form data
100+
if (str_contains($contentType, 'application/x-www-form-urlencoded')) {
101+
parse_str($body, $formData);
102+
103+
// @phpstan-ignore-next-line - parse_str can create numeric keys but we treat as string keys
104+
return $formData;
105+
}
106+
107+
// Return as string
108+
return $body;
109+
}
110+
}

src/Saloon.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ class Saloon
3636
*/
3737
protected array $recordedResponses = [];
3838

39+
/**
40+
* Track start time for Telescope duration calculation
41+
*
42+
* @var array<int, float>
43+
*/
44+
public static array $telescopeStartTimes = [];
45+
3946
/**
4047
* Start mocking!
4148
*

src/SaloonServiceProvider.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
use Saloon\Laravel\Http\Middleware\SendResponseEvent;
2222
use Saloon\Laravel\Console\Commands\MakeAuthenticator;
2323
use Saloon\Laravel\Http\Middleware\NightwatchMiddleware;
24+
use Saloon\Laravel\Http\Middleware\TelescopeRequestMiddleware;
25+
use Saloon\Laravel\Http\Middleware\TelescopeResponseMiddleware;
2426

2527
class SaloonServiceProvider extends ServiceProvider
2628
{
@@ -62,9 +64,11 @@ public function boot(): void
6264
Config::globalMiddleware()
6365
->onRequest(new MockMiddleware, 'laravelMock')
6466
->onRequest(new NightwatchMiddleware, 'laravelNightwatch')
67+
->onRequest(new TelescopeRequestMiddleware, 'laravelTelescopeRequest')
6568
->onRequest(new SendRequestEvent, 'laravelSendRequestEvent', PipeOrder::LAST)
6669
->onResponse(new RecordResponse, 'laravelRecordResponse', PipeOrder::FIRST)
67-
->onResponse(new SendResponseEvent, 'laravelSendResponseEvent', PipeOrder::FIRST);
70+
->onResponse(new SendResponseEvent, 'laravelSendResponseEvent', PipeOrder::FIRST)
71+
->onResponse(new TelescopeResponseMiddleware, 'laravelTelescopeResponse');
6872

6973
Saloon::$registeredDefaults = true;
7074
}
@@ -77,6 +81,7 @@ public function boot(): void
7781

7882
$this->app->terminating(function () {
7983
Saloon::$registeredSenders = [];
84+
Saloon::$telescopeStartTimes = [];
8085
});
8186
}
8287

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Saloon\Laravel\Saloon;
6+
use Saloon\Http\PendingRequest;
7+
use Saloon\Laravel\Tests\Fixtures\Requests\UserRequest;
8+
use Saloon\Laravel\Tests\Fixtures\Connectors\TestConnector;
9+
use Saloon\Laravel\Http\Middleware\TelescopeRequestMiddleware;
10+
use Saloon\Laravel\Http\Middleware\TelescopeResponseMiddleware;
11+
12+
test('telescope middleware handles sending event without errors when telescope is not available', function () {
13+
$connector = TestConnector::make();
14+
$request = new UserRequest();
15+
$pendingRequest = new PendingRequest($connector, $request);
16+
17+
$middleware = new TelescopeRequestMiddleware();
18+
19+
// Should not throw any exceptions even when Telescope is not available
20+
expect(function () use ($middleware, $pendingRequest) {
21+
$middleware->__invoke($pendingRequest);
22+
})->not->toThrow(Exception::class);
23+
});
24+
25+
test('telescope middleware works with any sender', function () {
26+
$connector = TestConnector::make();
27+
$request = new UserRequest();
28+
$pendingRequest = new PendingRequest($connector, $request);
29+
30+
$middleware = new TelescopeRequestMiddleware();
31+
32+
// Should handle gracefully for any sender type
33+
expect(function () use ($middleware, $pendingRequest) {
34+
$middleware->__invoke($pendingRequest);
35+
})->not->toThrow(Exception::class);
36+
});
37+
38+
test('telescope middleware tracks start time', function () {
39+
$connector = TestConnector::make();
40+
$request = new UserRequest();
41+
42+
expect(Saloon::$telescopeStartTimes)->toHaveCount(0);
43+
44+
$pendingRequest = new PendingRequest($connector, $request);
45+
46+
$middleware = new TelescopeRequestMiddleware();
47+
$middleware->__invoke($pendingRequest);
48+
49+
expect(Saloon::$telescopeStartTimes)->toHaveCount(1);
50+
});
51+
52+
test('telescope middleware calculates duration', function () {
53+
$connector = TestConnector::make();
54+
$request = new UserRequest();
55+
$pendingRequest = new PendingRequest($connector, $request);
56+
57+
// Create a mock response
58+
$psrRequest = $pendingRequest->createPsrRequest();
59+
$psrResponse = new \GuzzleHttp\Psr7\Response(200, [], '{"name":"Test"}');
60+
$response = \Saloon\Http\Response::fromPsrResponse($psrResponse, $pendingRequest, $psrRequest);
61+
62+
$middleware = new TelescopeRequestMiddleware();
63+
64+
// Handle sending event to set start time
65+
$middleware->__invoke($pendingRequest);
66+
67+
// Small delay
68+
usleep(1000); // 1ms
69+
70+
// Handle sent event
71+
expect(function () use ($response) {
72+
(new TelescopeResponseMiddleware)->__invoke($response);
73+
})->not->toThrow(Exception::class);
74+
});
75+
76+
test('telescope middleware formats json body', function () {
77+
$middleware = new TelescopeResponseMiddleware();
78+
79+
$reflection = new ReflectionClass($middleware);
80+
$method = $reflection->getMethod('formatBody');
81+
82+
$jsonBody = '{"name":"Test","value":123}';
83+
$formatted = $method->invoke($middleware, $jsonBody, 'application/json');
84+
85+
expect($formatted)->toBeArray();
86+
expect($formatted)->toHaveKeys(['name', 'value']);
87+
expect($formatted['name'])->toBe('Test');
88+
expect($formatted['value'])->toBe(123);
89+
});
90+
91+
test('telescope middleware formats form body', function () {
92+
$middleware = new TelescopeResponseMiddleware();
93+
94+
$reflection = new ReflectionClass($middleware);
95+
$method = $reflection->getMethod('formatBody');
96+
97+
$formBody = 'name=Test&value=123';
98+
$formatted = $method->invoke($middleware, $formBody, 'application/x-www-form-urlencoded');
99+
100+
expect($formatted)->toBeArray();
101+
expect($formatted)->toHaveKeys(['name', 'value']);
102+
expect($formatted['name'])->toBe('Test');
103+
expect($formatted['value'])->toBe('123');
104+
});
105+
106+
test('telescope middleware returns string for non-json non-form body', function () {
107+
$middleware = new TelescopeResponseMiddleware();
108+
109+
$reflection = new ReflectionClass($middleware);
110+
$method = $reflection->getMethod('formatBody');
111+
112+
$plainBody = 'plain text body';
113+
$formatted = $method->invoke($middleware, $plainBody, 'text/plain');
114+
115+
expect($formatted)->toBeString();
116+
expect($formatted)->toBe('plain text body');
117+
});

tests/Pest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@
1313
|
1414
*/
1515

16+
use Saloon\Laravel\Saloon;
1617
use Saloon\Laravel\Tests\TestCase;
1718

1819
uses(TestCase::class)->in('Feature', 'Unit');
1920

21+
pest()->afterEach(function () {
22+
Saloon::$registeredSenders = [];
23+
Saloon::$telescopeStartTimes = [];
24+
});
25+
2026
/*
2127
|--------------------------------------------------------------------------
2228
| Expectations

0 commit comments

Comments
 (0)