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: | |
24: | |
25: | |
26: | |
27: | class CurlerHarRecorder |
28: | { |
29: | |
30: | protected $Stream; |
31: | protected bool $IsCloseable; |
32: | protected ?string $Uri; |
33: | protected bool $IsRecording = false; |
34: | |
35: | protected array $ListenerIds; |
36: | protected ?RequestInterface $LastRequest = null; |
37: | protected float $LastRequestTime; |
38: | protected int $EntryCount = 0; |
39: | |
40: | |
41: | |
42: | |
43: | |
44: | |
45: | |
46: | |
47: | |
48: | |
49: | |
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: | |
63: | $name = Package::name(); |
64: | $version = Package::version(true, true); |
65: | |
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: | |
79: | |
80: | public function __destruct() |
81: | { |
82: | $this->close(); |
83: | } |
84: | |
85: | |
86: | |
87: | |
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: | |
109: | |
110: | |
111: | |
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: | |
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: | |
169: | throw new RuntimeException('Response does not match request'); |
170: | |
171: | } |
172: | $requestTime = $this->LastRequestTime; |
173: | |
174: | $this->LastRequest = null; |
175: | unset($this->LastRequestTime); |
176: | |
177: | $handle = $event->getCurlHandle(); |
178: | |
179: | |
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: | |
186: | |
187: | |
188: | |
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: | |
206: | $scheme = $this->getCurlInfo($handle, \CURLINFO_SCHEME); |
207: | $ssl = strcasecmp($scheme, 'https') === 0; |
208: | |
209: | $primaryIP = $this->getCurlInfo($handle, \CURLINFO_PRIMARY_IP); |
210: | |
211: | $entry = [ |
212: | |
213: | 'startedDateTime' => (new DateTimeImmutable(sprintf('@%.6f', $requestTime)))->format('Y-m-d\TH:i:s.vP'), |
214: | |
215: | 'time' => $totalTime, |
216: | 'request' => HttpRequest::fromPsr7($request)->jsonSerialize(), |
217: | 'response' => $event->getResponse()->jsonSerialize(), |
218: | 'cache' => new stdClass(), |
219: | 'timings' => [ |
220: | |
221: | 'blocked' => -1, |
222: | |
223: | 'dns' => $timings['namelookup'], |
224: | |
225: | 'connect' => $timings['connect'] + $timings['appconnect'], |
226: | |
227: | 'send' => $timings['pretransfer'], |
228: | |
229: | 'wait' => $timings['starttransfer'], |
230: | |
231: | 'receive' => $timings['transfer'], |
232: | |
233: | 'ssl' => $ssl ? $timings['appconnect'] : -1, |
234: | ], |
235: | |
236: | 'serverIPAddress' => $primaryIP, |
237: | ]; |
238: | |
239: | $this->writeEntry($entry); |
240: | } |
241: | |
242: | |
243: | |
244: | |
245: | |
246: | protected function getCurlInfo($handle, int $option) |
247: | { |
248: | $value = curl_getinfo($handle, $option); |
249: | if ($value === false) { |
250: | |
251: | throw new RuntimeException('Error getting cURL transfer information'); |
252: | |
253: | } |
254: | |
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: | |
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: | |
300: | |
301: | protected function assertIsValid(): void |
302: | { |
303: | if (!$this->Stream) { |
304: | throw new LogicException('Recorder is closed'); |
305: | } |
306: | } |
307: | } |
308: | |