1<?php declare(strict_types=1); 2 3/* 4 * This file is part of the Monolog package. 5 * 6 * (c) Jordi Boggiano <j.boggiano@seld.be> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Monolog\Handler; 13 14use Monolog\Logger; 15 16/** 17 * Stores to any socket - uses fsockopen() or pfsockopen(). 18 * 19 * @author Pablo de Leon Belloc <pablolb@gmail.com> 20 * @see http://php.net/manual/en/function.fsockopen.php 21 * 22 * @phpstan-import-type Record from \Monolog\Logger 23 * @phpstan-import-type FormattedRecord from AbstractProcessingHandler 24 */ 25class SocketHandler extends AbstractProcessingHandler 26{ 27 /** @var string */ 28 private $connectionString; 29 /** @var float */ 30 private $connectionTimeout; 31 /** @var resource|null */ 32 private $resource; 33 /** @var float */ 34 private $timeout; 35 /** @var float */ 36 private $writingTimeout; 37 /** @var ?int */ 38 private $lastSentBytes = null; 39 /** @var ?int */ 40 private $chunkSize; 41 /** @var bool */ 42 private $persistent; 43 /** @var ?int */ 44 private $errno = null; 45 /** @var ?string */ 46 private $errstr = null; 47 /** @var ?float */ 48 private $lastWritingAt = null; 49 50 /** 51 * @param string $connectionString Socket connection string 52 * @param bool $persistent Flag to enable/disable persistent connections 53 * @param float $timeout Socket timeout to wait until the request is being aborted 54 * @param float $writingTimeout Socket timeout to wait until the request should've been sent/written 55 * @param float|null $connectionTimeout Socket connect timeout to wait until the connection should've been 56 * established 57 * @param int|null $chunkSize Sets the chunk size. Only has effect during connection in the writing cycle 58 * 59 * @throws \InvalidArgumentException If an invalid timeout value (less than 0) is passed. 60 */ 61 public function __construct( 62 string $connectionString, 63 $level = Logger::DEBUG, 64 bool $bubble = true, 65 bool $persistent = false, 66 float $timeout = 0.0, 67 float $writingTimeout = 10.0, 68 ?float $connectionTimeout = null, 69 ?int $chunkSize = null 70 ) { 71 parent::__construct($level, $bubble); 72 $this->connectionString = $connectionString; 73 74 if ($connectionTimeout !== null) { 75 $this->validateTimeout($connectionTimeout); 76 } 77 78 $this->connectionTimeout = $connectionTimeout ?? (float) ini_get('default_socket_timeout'); 79 $this->persistent = $persistent; 80 $this->validateTimeout($timeout); 81 $this->timeout = $timeout; 82 $this->validateTimeout($writingTimeout); 83 $this->writingTimeout = $writingTimeout; 84 $this->chunkSize = $chunkSize; 85 } 86 87 /** 88 * Connect (if necessary) and write to the socket 89 * 90 * {@inheritDoc} 91 * 92 * @throws \UnexpectedValueException 93 * @throws \RuntimeException 94 */ 95 protected function write(array $record): void 96 { 97 $this->connectIfNotConnected(); 98 $data = $this->generateDataStream($record); 99 $this->writeToSocket($data); 100 } 101 102 /** 103 * We will not close a PersistentSocket instance so it can be reused in other requests. 104 */ 105 public function close(): void 106 { 107 if (!$this->isPersistent()) { 108 $this->closeSocket(); 109 } 110 } 111 112 /** 113 * Close socket, if open 114 */ 115 public function closeSocket(): void 116 { 117 if (is_resource($this->resource)) { 118 fclose($this->resource); 119 $this->resource = null; 120 } 121 } 122 123 /** 124 * Set socket connection to be persistent. It only has effect before the connection is initiated. 125 */ 126 public function setPersistent(bool $persistent): self 127 { 128 $this->persistent = $persistent; 129 130 return $this; 131 } 132 133 /** 134 * Set connection timeout. Only has effect before we connect. 135 * 136 * @see http://php.net/manual/en/function.fsockopen.php 137 */ 138 public function setConnectionTimeout(float $seconds): self 139 { 140 $this->validateTimeout($seconds); 141 $this->connectionTimeout = $seconds; 142 143 return $this; 144 } 145 146 /** 147 * Set write timeout. Only has effect before we connect. 148 * 149 * @see http://php.net/manual/en/function.stream-set-timeout.php 150 */ 151 public function setTimeout(float $seconds): self 152 { 153 $this->validateTimeout($seconds); 154 $this->timeout = $seconds; 155 156 return $this; 157 } 158 159 /** 160 * Set writing timeout. Only has effect during connection in the writing cycle. 161 * 162 * @param float $seconds 0 for no timeout 163 */ 164 public function setWritingTimeout(float $seconds): self 165 { 166 $this->validateTimeout($seconds); 167 $this->writingTimeout = $seconds; 168 169 return $this; 170 } 171 172 /** 173 * Set chunk size. Only has effect during connection in the writing cycle. 174 */ 175 public function setChunkSize(int $bytes): self 176 { 177 $this->chunkSize = $bytes; 178 179 return $this; 180 } 181 182 /** 183 * Get current connection string 184 */ 185 public function getConnectionString(): string 186 { 187 return $this->connectionString; 188 } 189 190 /** 191 * Get persistent setting 192 */ 193 public function isPersistent(): bool 194 { 195 return $this->persistent; 196 } 197 198 /** 199 * Get current connection timeout setting 200 */ 201 public function getConnectionTimeout(): float 202 { 203 return $this->connectionTimeout; 204 } 205 206 /** 207 * Get current in-transfer timeout 208 */ 209 public function getTimeout(): float 210 { 211 return $this->timeout; 212 } 213 214 /** 215 * Get current local writing timeout 216 * 217 * @return float 218 */ 219 public function getWritingTimeout(): float 220 { 221 return $this->writingTimeout; 222 } 223 224 /** 225 * Get current chunk size 226 */ 227 public function getChunkSize(): ?int 228 { 229 return $this->chunkSize; 230 } 231 232 /** 233 * Check to see if the socket is currently available. 234 * 235 * UDP might appear to be connected but might fail when writing. See http://php.net/fsockopen for details. 236 */ 237 public function isConnected(): bool 238 { 239 return is_resource($this->resource) 240 && !feof($this->resource); // on TCP - other party can close connection. 241 } 242 243 /** 244 * Wrapper to allow mocking 245 * 246 * @return resource|false 247 */ 248 protected function pfsockopen() 249 { 250 return @pfsockopen($this->connectionString, -1, $this->errno, $this->errstr, $this->connectionTimeout); 251 } 252 253 /** 254 * Wrapper to allow mocking 255 * 256 * @return resource|false 257 */ 258 protected function fsockopen() 259 { 260 return @fsockopen($this->connectionString, -1, $this->errno, $this->errstr, $this->connectionTimeout); 261 } 262 263 /** 264 * Wrapper to allow mocking 265 * 266 * @see http://php.net/manual/en/function.stream-set-timeout.php 267 * 268 * @return bool 269 */ 270 protected function streamSetTimeout() 271 { 272 $seconds = floor($this->timeout); 273 $microseconds = round(($this->timeout - $seconds) * 1e6); 274 275 if (!is_resource($this->resource)) { 276 throw new \LogicException('streamSetTimeout called but $this->resource is not a resource'); 277 } 278 279 return stream_set_timeout($this->resource, (int) $seconds, (int) $microseconds); 280 } 281 282 /** 283 * Wrapper to allow mocking 284 * 285 * @see http://php.net/manual/en/function.stream-set-chunk-size.php 286 * 287 * @return int|bool 288 */ 289 protected function streamSetChunkSize() 290 { 291 if (!is_resource($this->resource)) { 292 throw new \LogicException('streamSetChunkSize called but $this->resource is not a resource'); 293 } 294 295 if (null === $this->chunkSize) { 296 throw new \LogicException('streamSetChunkSize called but $this->chunkSize is not set'); 297 } 298 299 return stream_set_chunk_size($this->resource, $this->chunkSize); 300 } 301 302 /** 303 * Wrapper to allow mocking 304 * 305 * @return int|bool 306 */ 307 protected function fwrite(string $data) 308 { 309 if (!is_resource($this->resource)) { 310 throw new \LogicException('fwrite called but $this->resource is not a resource'); 311 } 312 313 return @fwrite($this->resource, $data); 314 } 315 316 /** 317 * Wrapper to allow mocking 318 * 319 * @return mixed[]|bool 320 */ 321 protected function streamGetMetadata() 322 { 323 if (!is_resource($this->resource)) { 324 throw new \LogicException('streamGetMetadata called but $this->resource is not a resource'); 325 } 326 327 return stream_get_meta_data($this->resource); 328 } 329 330 private function validateTimeout(float $value): void 331 { 332 if ($value < 0) { 333 throw new \InvalidArgumentException("Timeout must be 0 or a positive float (got $value)"); 334 } 335 } 336 337 private function connectIfNotConnected(): void 338 { 339 if ($this->isConnected()) { 340 return; 341 } 342 $this->connect(); 343 } 344 345 /** 346 * @phpstan-param FormattedRecord $record 347 */ 348 protected function generateDataStream(array $record): string 349 { 350 return (string) $record['formatted']; 351 } 352 353 /** 354 * @return resource|null 355 */ 356 protected function getResource() 357 { 358 return $this->resource; 359 } 360 361 private function connect(): void 362 { 363 $this->createSocketResource(); 364 $this->setSocketTimeout(); 365 $this->setStreamChunkSize(); 366 } 367 368 private function createSocketResource(): void 369 { 370 if ($this->isPersistent()) { 371 $resource = $this->pfsockopen(); 372 } else { 373 $resource = $this->fsockopen(); 374 } 375 if (is_bool($resource)) { 376 throw new \UnexpectedValueException("Failed connecting to $this->connectionString ($this->errno: $this->errstr)"); 377 } 378 $this->resource = $resource; 379 } 380 381 private function setSocketTimeout(): void 382 { 383 if (!$this->streamSetTimeout()) { 384 throw new \UnexpectedValueException("Failed setting timeout with stream_set_timeout()"); 385 } 386 } 387 388 private function setStreamChunkSize(): void 389 { 390 if ($this->chunkSize && !$this->streamSetChunkSize()) { 391 throw new \UnexpectedValueException("Failed setting chunk size with stream_set_chunk_size()"); 392 } 393 } 394 395 private function writeToSocket(string $data): void 396 { 397 $length = strlen($data); 398 $sent = 0; 399 $this->lastSentBytes = $sent; 400 while ($this->isConnected() && $sent < $length) { 401 if (0 == $sent) { 402 $chunk = $this->fwrite($data); 403 } else { 404 $chunk = $this->fwrite(substr($data, $sent)); 405 } 406 if ($chunk === false) { 407 throw new \RuntimeException("Could not write to socket"); 408 } 409 $sent += $chunk; 410 $socketInfo = $this->streamGetMetadata(); 411 if (is_array($socketInfo) && $socketInfo['timed_out']) { 412 throw new \RuntimeException("Write timed-out"); 413 } 414 415 if ($this->writingIsTimedOut($sent)) { 416 throw new \RuntimeException("Write timed-out, no data sent for `{$this->writingTimeout}` seconds, probably we got disconnected (sent $sent of $length)"); 417 } 418 } 419 if (!$this->isConnected() && $sent < $length) { 420 throw new \RuntimeException("End-of-file reached, probably we got disconnected (sent $sent of $length)"); 421 } 422 } 423 424 private function writingIsTimedOut(int $sent): bool 425 { 426 // convert to ms 427 if (0.0 == $this->writingTimeout) { 428 return false; 429 } 430 431 if ($sent !== $this->lastSentBytes) { 432 $this->lastWritingAt = microtime(true); 433 $this->lastSentBytes = $sent; 434 435 return false; 436 } else { 437 usleep(100); 438 } 439 440 if ((microtime(true) - $this->lastWritingAt) >= $this->writingTimeout) { 441 $this->closeSocket(); 442 443 return true; 444 } 445 446 return false; 447 } 448} 449