1: <?php declare(strict_types=1);
2:
3: namespace Salient\Curler;
4:
5: use Psr\Http\Message\RequestInterface;
6: use Salient\Contract\Curler\Event\CurlRequestEventInterface;
7: use Salient\Contract\Curler\Event\CurlResponseEventInterface;
8: use Salient\Contract\Curler\Event\ResponseCacheHitEventInterface;
9: use Salient\Core\Facade\Event;
10: use Salient\Http\HttpRequest;
11: use Salient\Utility\File;
12: use Salient\Utility\Json;
13: use Salient\Utility\Package;
14: use Closure;
15: use CurlHandle;
16: use DateTimeImmutable;
17: use LogicException;
18: use RuntimeException;
19: use stdClass;
20: use Stringable;
21:
22: /**
23: * Records Curler requests to an HTTP Archive (HAR) stream
24: *
25: * @api
26: */
27: class CurlerHarRecorder
28: {
29: /** @var resource|null */
30: protected $Stream;
31: protected bool $IsCloseable;
32: protected ?string $Uri;
33: protected bool $IsRecording = false;
34: /** @var int[] */
35: protected array $ListenerIds;
36: protected ?RequestInterface $LastRequest = null;
37: protected float $LastRequestTime;
38: protected int $EntryCount = 0;
39:
40: /**
41: * Creates a new CurlerHarRecorder object
42: *
43: * `$name` and `$version` are applied to the archive's `<creator>` object.
44: * If both are `null`, they are replaced with the name and version of the
45: * root package.
46: *
47: * @api
48: *
49: * @param Stringable|string|resource $resource
50: */
51: public function __construct(
52: $resource,
53: ?string $name = null,
54: ?string $version = null
55: ) {
56: $uri = null;
57: $this->Stream = File::maybeOpen($resource, 'w', $close, $uri);
58: $this->IsCloseable = $close;
59: $this->Uri = $uri;
60:
61: if ($name === null && $version === null) {
62: // @codeCoverageIgnoreStart
63: $name = Package::name();
64: $version = Package::version(true, true);
65: // @codeCoverageIgnoreEnd
66: }
67:
68: File::writeAll($this->Stream, sprintf(
69: '{"log":{"version":"1.2","creator":%s,"pages":[],"entries":[',
70: Json::stringify([
71: 'name' => $name ?? '',
72: 'version' => $version ?? '',
73: ]),
74: ), null, $this->Uri);
75: }
76:
77: /**
78: * @internal
79: */
80: public function __destruct()
81: {
82: $this->close();
83: }
84:
85: /**
86: * Close the HTTP Archive (HAR) stream or detach it from the instance,
87: * stopping recording if necessary
88: */
89: public function close(): void
90: {
91: $this->stop();
92:
93: if (!$this->Stream) {
94: return;
95: }
96:
97: File::writeAll($this->Stream, ']}}', null, $this->Uri);
98:
99: if ($this->IsCloseable) {
100: File::close($this->Stream, $this->Uri);
101: }
102:
103: $this->Stream = null;
104: $this->Uri = null;
105: }
106:
107: /**
108: * Start recording requests, optionally with an initial event
109: *
110: * @param CurlRequestEventInterface|ResponseCacheHitEventInterface|null $event
111: * @throws LogicException if the instance is already recording.
112: */
113: public function start($event = null): void
114: {
115: if ($this->IsRecording) {
116: throw new LogicException('Already recording');
117: }
118:
119: $this->assertIsValid();
120:
121: $this->IsRecording = true;
122:
123: $dispatcher = Event::getInstance();
124: $this->ListenerIds = [
125: $dispatcher->listen(Closure::fromCallable([$this, 'handleCurlRequest'])),
126: $dispatcher->listen(Closure::fromCallable([$this, 'handleCurlResponse'])),
127: $dispatcher->listen(Closure::fromCallable([$this, 'handleResponseCacheHit'])),
128: ];
129:
130: if ($event instanceof CurlRequestEventInterface) {
131: $this->handleCurlRequest($event);
132: } elseif ($event instanceof ResponseCacheHitEventInterface) {
133: $this->handleResponseCacheHit($event);
134: }
135: }
136:
137: /**
138: * Stop recording requests if they are being recorded
139: */
140: public function stop(): void
141: {
142: if (!$this->IsRecording) {
143: return;
144: }
145:
146: foreach ($this->ListenerIds as $id) {
147: Event::removeListener($id);
148: }
149: unset($this->ListenerIds);
150:
151: $this->IsRecording = false;
152: }
153:
154: protected function handleCurlRequest(CurlRequestEventInterface $event): void
155: {
156: $this->assertIsValid();
157:
158: $this->LastRequest = $event->getRequest();
159: $this->LastRequestTime = microtime(true);
160: }
161:
162: protected function handleCurlResponse(CurlResponseEventInterface $event): void
163: {
164: $this->assertIsValid();
165:
166: $request = $event->getRequest();
167: if ($request !== $this->LastRequest) {
168: // @codeCoverageIgnoreStart
169: throw new RuntimeException('Response does not match request');
170: // @codeCoverageIgnoreEnd
171: }
172: $requestTime = $this->LastRequestTime;
173:
174: $this->LastRequest = null;
175: unset($this->LastRequestTime);
176:
177: $handle = $event->getCurlHandle();
178:
179: /** @var int */
180: $redirects = $this->getCurlInfo($handle, \CURLINFO_REDIRECT_COUNT);
181: if ($redirects !== 0) {
182: throw new RuntimeException('Redirects followed by cURL cannot be recorded');
183: }
184:
185: // According to https://curl.se/libcurl/c/curl_easy_getinfo.html, cURL
186: // transfers are processed in the following order, but connection reuse
187: // can lead to variations:
188: /** @var array{namelookup:int,connect:int,appconnect:int,pretransfer:int,starttransfer:int,transfer:int} */
189: $times = [
190: 'namelookup' => $this->getCurlInfo($handle, \CURLINFO_NAMELOOKUP_TIME_T),
191: 'connect' => $this->getCurlInfo($handle, \CURLINFO_CONNECT_TIME_T),
192: 'appconnect' => $this->getCurlInfo($handle, \CURLINFO_APPCONNECT_TIME_T),
193: 'pretransfer' => $this->getCurlInfo($handle, \CURLINFO_PRETRANSFER_TIME_T),
194: 'starttransfer' => $this->getCurlInfo($handle, \CURLINFO_STARTTRANSFER_TIME_T),
195: 'transfer' => $this->getCurlInfo($handle, \CURLINFO_TOTAL_TIME_T),
196: ];
197:
198: $totalTime = 0;
199: $last = 0;
200: foreach ($times as $time => $value) {
201: $totalTime += $timings[$time] = (int) round(max(0, $value - $last) / 1000);
202: $last = $value;
203: }
204:
205: /** @var string */
206: $scheme = $this->getCurlInfo($handle, \CURLINFO_SCHEME);
207: $ssl = strcasecmp($scheme, 'https') === 0;
208: /** @var string */
209: $primaryIP = $this->getCurlInfo($handle, \CURLINFO_PRIMARY_IP);
210:
211: $entry = [
212: // PHP 7.4 requires 6 digits after the decimal point
213: 'startedDateTime' => (new DateTimeImmutable(sprintf('@%.6f', $requestTime)))->format('Y-m-d\TH:i:s.vP'),
214: // Sum of non-negative timings
215: 'time' => $totalTime,
216: 'request' => HttpRequest::fromPsr7($request)->jsonSerialize(),
217: 'response' => $event->getResponse()->jsonSerialize(),
218: 'cache' => new stdClass(),
219: 'timings' => [
220: // Time in queue
221: 'blocked' => -1,
222: // DNS resolution time
223: 'dns' => $timings['namelookup'],
224: // Time creating connection, including SSL/TLS negotiation
225: 'connect' => $timings['connect'] + $timings['appconnect'],
226: // Time sending request (must be non-negative)
227: 'send' => $timings['pretransfer'],
228: // Time waiting for response (must be non-negative)
229: 'wait' => $timings['starttransfer'],
230: // Time receiving response (must be non-negative)
231: 'receive' => $timings['transfer'],
232: // Optional (1.2+) SSL/TLS negotiation time
233: 'ssl' => $ssl ? $timings['appconnect'] : -1,
234: ],
235: // Optional (1.2+)
236: 'serverIPAddress' => $primaryIP,
237: ];
238:
239: $this->writeEntry($entry);
240: }
241:
242: /**
243: * @param CurlHandle|resource $handle
244: * @return mixed[]|int|float|string|null
245: */
246: protected function getCurlInfo($handle, int $option)
247: {
248: $value = curl_getinfo($handle, $option);
249: if ($value === false) {
250: // @codeCoverageIgnoreStart
251: throw new RuntimeException('Error getting cURL transfer information');
252: // @codeCoverageIgnoreEnd
253: }
254: /** @var mixed[]|int|float|string|null */
255: return $value;
256: }
257:
258: protected function handleResponseCacheHit(ResponseCacheHitEventInterface $event): void
259: {
260: $this->assertIsValid();
261:
262: $request = $event->getRequest();
263:
264: $entry = [
265: 'startedDateTime' => (new DateTimeImmutable())->format('Y-m-d\TH:i:s.vP'),
266: 'time' => 0,
267: 'request' => HttpRequest::fromPsr7($request)->jsonSerialize(),
268: 'response' => $event->getResponse()->jsonSerialize(),
269: 'cache' => new stdClass(),
270: 'timings' => [
271: 'blocked' => -1,
272: 'dns' => -1,
273: 'connect' => -1,
274: 'send' => 0,
275: 'wait' => 0,
276: 'receive' => 0,
277: ],
278: ];
279:
280: $this->writeEntry($entry);
281: }
282:
283: /**
284: * @param array<string,mixed> $entry
285: */
286: protected function writeEntry(array $entry): void
287: {
288: $this->assertIsValid();
289:
290: File::writeAll(
291: $this->Stream,
292: ($this->EntryCount++ ? ',' : '') . Json::stringify($entry),
293: null,
294: $this->Uri,
295: );
296: }
297:
298: /**
299: * @phpstan-assert !null $this->Stream
300: */
301: protected function assertIsValid(): void
302: {
303: if (!$this->Stream) {
304: throw new LogicException('Recorder is closed');
305: }
306: }
307: }
308: