Skip to content

Commit 16aa337

Browse files
authored
Merge pull request #693 from itsgoingd/feat-laravel-http-client-data-source
Support for collecting HTTP client requests
2 parents f128969 + 72fa2e0 commit 16aa337

6 files changed

Lines changed: 336 additions & 0 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php namespace Clockwork\DataSource;
2+
3+
use Clockwork\Helpers\Serializer;
4+
use Clockwork\Helpers\StackTrace;
5+
use Clockwork\Request\Request;
6+
7+
use GuzzleHttp\Client;
8+
use GuzzleHttp\HandlerStack;
9+
use GuzzleHttp\TransferStats;
10+
use GuzzleHttp\Exception\GuzzleException;
11+
use GuzzleHttp\Exception\RequestException;
12+
use GuzzleHttp\Promise\PromiseInterface;
13+
use Psr\Http\Message\RequestInterface;
14+
use Psr\Http\Message\ResponseInterface;
15+
16+
// Data source for Guzzle HTTP client, provides executed HTTP requests
17+
class GuzzleDataSource extends DataSource
18+
{
19+
// Sent HTTP requests
20+
protected $requests = [];
21+
22+
// Whether to collect request and response content (json or form data) and raw content
23+
protected $collectContent = true;
24+
protected $collectRawContent = true;
25+
26+
// Create a new data source instance
27+
public function __construct($collectContent = true, $collectRawContent = false)
28+
{
29+
$this->collectContent = $collectContent;
30+
$this->collectRawContent = $collectRawContent;
31+
}
32+
33+
// Returns a new Guzzle instance, pre-configured with Clockwork support
34+
public function instance(array $config = [])
35+
{
36+
return new Client($this->configure($config));
37+
}
38+
39+
// Updates Guzzle configuration array with Clockwork support
40+
public function configure(array $config = [])
41+
{
42+
$handler = isset($config['handler']) ? $config['handler'] : HandlerStack::create();
43+
44+
$handler->push($this);
45+
46+
$config['handler'] = $handler;
47+
48+
return $config;
49+
}
50+
51+
// Add sent notifications to the request
52+
public function resolve(Request $request)
53+
{
54+
$request->httpRequests = array_merge($request->httpRequests, $this->requests);
55+
56+
return $request;
57+
}
58+
59+
// Reset the data source to an empty state, clearing any collected data
60+
public function reset()
61+
{
62+
$this->requests = [];
63+
$this->executingRequests = [];
64+
}
65+
66+
// Guzzle middleware implemenation, that does the requests logging itself
67+
public function __invoke(callable $handler): callable
68+
{
69+
return function(RequestInterface $request, array $options) use ($handler): PromiseInterface {
70+
$time = microtime(true);
71+
$stats = null;
72+
73+
$originalOnStats = isset($options['on_stats']) ? $options['on_stats'] : null;
74+
$options['on_stats'] = function (TransferStats $transferStats) use (&$stats, $originalOnStats) {
75+
$stats = $transferStats->getHandlerStats();
76+
if ($originalOnStats) $originalOnStats($transferStats);
77+
};
78+
79+
return $handler($request, $options)
80+
->then(function(ResponseInterface $response) use ($request, $time, $stats) {
81+
$this->collectRequest($request, $response, $time, $stats);
82+
83+
return $response;
84+
}, function(GuzzleException $exception) use ($request, $time, $stats) {
85+
$response = $exception instanceof RequestException ? $exception->getResponse() : null;
86+
$this->collectRequest($request, $response, $time, $stats);
87+
88+
throw $exception;
89+
});
90+
};
91+
}
92+
93+
// Collect a request-response pair
94+
protected function collectRequest($request, $response = null, $startTime = null, $stats = null)
95+
{
96+
$trace = StackTrace::get();
97+
98+
$request = (object) [
99+
'request' => (object) [
100+
'method' => $request->getMethod(),
101+
'url' => $this->removeAuthFromUrl((string) $request->getUri()),
102+
'headers' => $request->getHeaders(),
103+
'content' => $this->collectContent ? $this->resolveRequestContent($request) : null,
104+
'body' => $this->collectRawContent ? (string) $request->getBody() : null
105+
],
106+
'response' => (object) [
107+
'status' => (int) $response->getStatusCode(),
108+
'headers' => $response->getHeaders(),
109+
'content' => $this->collectContent ? json_decode((string) $response->getBody(), true) : null,
110+
'body' => $this->collectRawContent ? (string) $response->getBody() : null
111+
],
112+
'stats' => $stats ? (object) [
113+
'timing' => isset($stats['total_time_us']) ? (object) [
114+
'lookup' => $stats['namelookup_time_us'] / 1000,
115+
'connect' => ($stats['pretransfer_time_us'] - $stats['namelookup_time_us']) / 1000,
116+
'waiting' => ($stats['starttransfer_time_us'] - $stats['pretransfer_time_us']) / 1000,
117+
'transfer' => ($stats['total_time_us'] - $stats['starttransfer_time_us']) / 1000
118+
] : null,
119+
'size' => (object) [
120+
'upload' => isset($stats['size_upload']) ? $stats['size_upload'] : null,
121+
'download' => isset($stats['size_download']) ? $stats['size_download'] : null
122+
],
123+
'speed' => (object) [
124+
'upload' => isset($stats['speed_upload']) ? $stats['speed_upload'] : null,
125+
'download' => isset($stats['speed_download']) ? $stats['speed_download'] : null
126+
],
127+
'hosts' => (object) [
128+
'local' => isset($stats['local_ip']) ? [ 'ip' => $stats['local_ip'], 'port' => $stats['local_port'] ] : null,
129+
'remote' => isset($stats['primary_ip']) ? [ 'ip' => $stats['primary_ip'], 'port' => $stats['primary_port'] ] : null
130+
],
131+
'version' => isset($stats['http_version']) ? $stats['http_version'] : null
132+
] : null,
133+
'error' => null,
134+
'time' => $startTime,
135+
'duration' => (microtime(true) - $startTime) * 1000,
136+
'trace' => (new Serializer)->trace($trace)
137+
];
138+
139+
if ($this->passesFilters([ $request ])) {
140+
$this->requests[] = $request;
141+
}
142+
}
143+
144+
// Resolve request content, with support for form data and json requests
145+
protected function resolveRequestContent($request)
146+
{
147+
$body = (string) $request->getBody();
148+
$headers = $request->getHeaders();
149+
150+
if (isset($headers['Content-Type']) && $headers['Content-Type'][0] == 'application/x-www-form-urlencoded') {
151+
parse_str($body, $parameters);
152+
return $parameters;
153+
} elseif (isset($headers['Content-Type']) && strpos($headers['Content-Type'][0], 'json') !== false) {
154+
return json_decode($body, true);
155+
}
156+
157+
return [];
158+
}
159+
160+
// Removes username and password from the URL
161+
protected function removeAuthFromUrl($url)
162+
{
163+
return preg_replace('#^(.+?://)(.+?@)(.*)$#', '$1$3', $url);
164+
}
165+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php namespace Clockwork\DataSource;
2+
3+
use Clockwork\Helpers\Serializer;
4+
use Clockwork\Helpers\StackTrace;
5+
use Clockwork\Request\Request;
6+
7+
use Illuminate\Contracts\Events\Dispatcher;
8+
use Illuminate\Http\Client\Events\ConnectionFailed;
9+
use Illuminate\Http\Client\Events\RequestSending;
10+
use Illuminate\Http\Client\Events\ResponseReceived;
11+
12+
// Data source for Laravel HTTP client, provides executed HTTP requests
13+
class LaravelHttpClientDataSource extends DataSource
14+
{
15+
// Event dispatcher instance
16+
protected $dispatcher;
17+
18+
// Sent HTTP requests
19+
protected $requests = [];
20+
21+
// Map of executing requests, keyed by their object hash
22+
protected $executingRequests = [];
23+
24+
// Whether to collect request and response content (json or form data) and raw content
25+
protected $collectContent = true;
26+
protected $collectRawContent = true;
27+
28+
// Create a new data source instance, takes an event dispatcher as argument
29+
public function __construct(Dispatcher $dispatcher, $collectContent = true, $collectRawContent = false)
30+
{
31+
$this->dispatcher = $dispatcher;
32+
33+
$this->collectContent = $collectContent;
34+
$this->collectRawContent = $collectRawContent;
35+
}
36+
37+
// Add sent notifications to the request
38+
public function resolve(Request $request)
39+
{
40+
$request->httpRequests = array_merge($request->httpRequests, $this->requests);
41+
42+
return $request;
43+
}
44+
45+
// Reset the data source to an empty state, clearing any collected data
46+
public function reset()
47+
{
48+
$this->requests = [];
49+
$this->executingRequests = [];
50+
}
51+
52+
// Listen to the email and notification events
53+
public function listenToEvents()
54+
{
55+
$this->dispatcher->listen(ConnectionFailed::class, function ($event) { $this->connectionFailed($event); });
56+
$this->dispatcher->listen(RequestSending::class, function ($event) { $this->sendingRequest($event); });
57+
$this->dispatcher->listen(ResponseReceived::class, function ($event) { $this->responseReceived($event); });
58+
}
59+
60+
// Collect an executing request
61+
protected function sendingRequest(RequestSending $event)
62+
{
63+
$trace = StackTrace::get()->resolveViewName();
64+
65+
$request = (object) [
66+
'request' => (object) [
67+
'method' => $event->request->method(),
68+
'url' => $this->removeAuthFromUrl($event->request->url()),
69+
'headers' => $event->request->headers(),
70+
'content' => $this->collectContent ? $event->request->data() : null,
71+
'body' => $this->collectRawContent ? $event->request->body() : null
72+
],
73+
'response' => null,
74+
'stats' => null,
75+
'error' => null,
76+
'time' => microtime(true),
77+
'trace' => (new Serializer)->trace($trace)
78+
];
79+
80+
if ($this->passesFilters([ $request ])) {
81+
$this->requests[] = $this->executingRequests[spl_object_hash($event->request)] = $request;
82+
}
83+
}
84+
85+
// Update last request with response details and time taken
86+
protected function responseReceived($event)
87+
{
88+
if (! isset($this->executingRequests[spl_object_hash($event->request)])) return;
89+
90+
$request = $this->executingRequests[spl_object_hash($event->request)];
91+
$stats = $event->response->handlerStats();
92+
93+
$request->duration = (microtime(true) - $request->time) * 1000;
94+
$request->response = (object) [
95+
'status' => $event->response->status(),
96+
'headers' => $event->response->headers(),
97+
'content' => $this->collectContent ? $event->response->json() : null,
98+
'body' => $this->collectRawContent ? $event->response->body() : null
99+
];
100+
$request->stats = (object) [
101+
'timing' => isset($stats['total_time_us']) ? (object) [
102+
'lookup' => $stats['namelookup_time_us'] / 1000,
103+
'connect' => ($stats['pretransfer_time_us'] - $stats['namelookup_time_us']) / 1000,
104+
'waiting' => ($stats['starttransfer_time_us'] - $stats['pretransfer_time_us']) / 1000,
105+
'transfer' => ($stats['total_time_us'] - $stats['starttransfer_time_us']) / 1000
106+
] : null,
107+
'size' => (object) [
108+
'upload' => isset($stats['size_upload']) ? $stats['size_upload'] : null,
109+
'download' => isset($stats['size_download']) ? $stats['size_download'] : null
110+
],
111+
'speed' => (object) [
112+
'upload' => isset($stats['speed_upload']) ? $stats['speed_upload'] : null,
113+
'download' => isset($stats['speed_download']) ? $stats['speed_download'] : null
114+
],
115+
'hosts' => (object) [
116+
'local' => isset($stats['local_ip']) ? [ 'ip' => $stats['local_ip'], 'port' => $stats['local_port'] ] : null,
117+
'remote' => isset($stats['primary_ip']) ? [ 'ip' => $stats['primary_ip'], 'port' => $stats['primary_port'] ] : null
118+
],
119+
'version' => isset($stats['http_version']) ? $stats['http_version'] : null
120+
];
121+
122+
unset($this->executingRequests[spl_object_hash($event->request)]);
123+
}
124+
125+
// Update last request with error when connection fails
126+
protected function connectionFailed($event)
127+
{
128+
if (! isset($this->executingRequests[spl_object_hash($event->request)])) return;
129+
130+
$request = $this->executingRequests[spl_object_hash($event->request)];
131+
132+
$request->duration = (microtime(true) - $request->time) * 1000;
133+
$request->error = 'connection-failed';
134+
135+
unset($this->executingRequests[spl_object_hash($event->request)]);
136+
}
137+
138+
// Removes username and password from the URL
139+
protected function removeAuthFromUrl($url)
140+
{
141+
return preg_replace('#^(.+?://)(.+?@)(.*)$#', '$1$3', $url);
142+
}
143+
}

Clockwork/Request/Request.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ class Request
133133
// Custom user data
134134
public $userData = [];
135135

136+
// HTTP requests
137+
public $httpRequests = [];
138+
136139
// Subrequests
137140
public $subrequests = [];
138141

@@ -298,6 +301,7 @@ public function toArray()
298301
'userData' => array_map(function ($data) {
299302
return $data instanceof UserData ? $data->toArray() : $data;
300303
}, $this->userData),
304+
'httpRequests' => $this->httpRequests,
301305
'subrequests' => $this->subrequests,
302306
'xdebug' => $this->xdebug,
303307
'commandName' => $this->commandName,

Clockwork/Support/Laravel/ClockworkServiceProvider.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Clockwork\DataSource\LaravelCacheDataSource;
77
use Clockwork\DataSource\LaravelDataSource;
88
use Clockwork\DataSource\LaravelEventsDataSource;
9+
use Clockwork\DataSource\LaravelHttpClientDataSource;
910
use Clockwork\DataSource\LaravelNotificationsDataSource;
1011
use Clockwork\DataSource\LaravelQueueDataSource;
1112
use Clockwork\DataSource\LaravelRedisDataSource;
@@ -153,6 +154,14 @@ protected function registerDataSources()
153154
));
154155
});
155156

157+
$this->app->singleton('clockwork.http-requests', function ($app) {
158+
return new LaravelHttpClientDataSource(
159+
$app['events'],
160+
$app['clockwork.support']->getConfig('features.http_requests.collect_data'),
161+
$app['clockwork.support']->getConfig('features.http_requests.collect_raw_data')
162+
);
163+
});
164+
156165
$this->app->singleton('clockwork.laravel', function ($app) {
157166
return (new LaravelDataSource(
158167
$app,

Clockwork/Support/Laravel/ClockworkSupport.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ public function addDataSources()
160160
? $this->app['clockwork.notifications'] : $this->app['clockwork.swift']
161161
);
162162
}
163+
if ($this->isFeatureEnabled('http_requests')) $clockwork->addDataSource($this->app['clockwork.http-requests']);
163164
if ($this->isFeatureAvailable('xdebug')) $clockwork->addDataSource($this->app['clockwork.xdebug']);
164165
if ($this->isFeatureEnabled('views')) {
165166
$clockwork->addDataSource(
@@ -179,6 +180,7 @@ public function listenToEvents()
179180
if ($this->isFeatureEnabled('cache')) $this->app['clockwork.cache']->listenToEvents();
180181
if ($this->isFeatureEnabled('database')) $this->app['clockwork.eloquent']->listenToEvents();
181182
if ($this->isFeatureEnabled('events')) $this->app['clockwork.events']->listenToEvents();
183+
if ($this->isFeatureEnabled('http_requests')) $this->app['clockwork.http-requests']->listenToEvents();
182184
if ($this->isFeatureEnabled('notifications')) {
183185
$this->isFeatureAvailable('notifications-events')
184186
? $this->app['clockwork.notifications']->listenToEvents() : $this->app['clockwork.swift']->listenToEvents();
@@ -587,6 +589,8 @@ public function isFeatureAvailable($feature)
587589
{
588590
if ($feature == 'database') {
589591
return $this->app['config']->get('database.default');
592+
} elseif ($feature == 'http_requests') {
593+
return class_exists(\Illuminate\Http\Client\Request::class);
590594
} elseif ($feature == 'notifications-events') {
591595
return class_exists(\Illuminate\Mail\Events\MessageSent::class)
592596
&& class_exists(\Illuminate\Notifications\Events\NotificationSent::class);

Clockwork/Support/Laravel/config/clockwork.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@
7272
],
7373
],
7474

75+
// Sent HTTP requests
76+
'http_requests' => [
77+
'enabled' => env('CLOCKWORK_HTTP_REQUESTS_ENABLED', true),
78+
79+
// Collect structured request and response content (json and form data)
80+
'collect_data' => env('CLOCKWORK_HTTP_REQUESTS_COLLECT_DATA', true),
81+
82+
// Collect raw request and response content (high storage usage with large responses)
83+
'collect_raw_data' => env('CLOCKWORK_HTTP_REQUESTS_COLLECT_RAW_DATA', false)
84+
],
85+
7586
// Laravel log (you can still log directly to Clockwork with laravel log disabled)
7687
'log' => [
7788
'enabled' => env('CLOCKWORK_LOG_ENABLED', true)

0 commit comments

Comments
 (0)