vendor/monolog/monolog/src/Monolog/Handler/StreamHandler.php line 25

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. /*
  3. * This file is part of the Monolog package.
  4. *
  5. * (c) Jordi Boggiano <j.boggiano@seld.be>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Monolog\Handler;
  11. use Monolog\Level;
  12. use Monolog\Utils;
  13. use Monolog\LogRecord;
  14. /**
  15. * Stores to any stream resource
  16. *
  17. * Can be used to store into php://stderr, remote and local files, etc.
  18. *
  19. * @author Jordi Boggiano <j.boggiano@seld.be>
  20. */
  21. class StreamHandler extends AbstractProcessingHandler
  22. {
  23. protected const MAX_CHUNK_SIZE = 2147483647;
  24. /** 10MB */
  25. protected const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024;
  26. protected int $streamChunkSize;
  27. /** @var resource|null */
  28. protected $stream;
  29. protected string|null $url = null;
  30. private string|null $errorMessage = null;
  31. protected int|null $filePermission;
  32. protected bool $useLocking;
  33. protected string $fileOpenMode;
  34. /** @var true|null */
  35. private bool|null $dirCreated = null;
  36. private bool $retrying = false;
  37. private int|null $inodeUrl = null;
  38. /**
  39. * @param resource|string $stream If a missing path can't be created, an UnexpectedValueException will be thrown on first write
  40. * @param int|null $filePermission Optional file permissions (default (0644) are only for owner read/write)
  41. * @param bool $useLocking Try to lock log file before doing any writes
  42. * @param string $fileOpenMode The fopen() mode used when opening a file, if $stream is a file path
  43. *
  44. * @throws \InvalidArgumentException If stream is not a resource or string
  45. */
  46. public function __construct($stream, int|string|Level $level = Level::Debug, bool $bubble = true, ?int $filePermission = null, bool $useLocking = false, string $fileOpenMode = 'a')
  47. {
  48. parent::__construct($level, $bubble);
  49. if (($phpMemoryLimit = Utils::expandIniShorthandBytes(\ini_get('memory_limit'))) !== false) {
  50. if ($phpMemoryLimit > 0) {
  51. // use max 10% of allowed memory for the chunk size, and at least 100KB
  52. $this->streamChunkSize = min(static::MAX_CHUNK_SIZE, max((int) ($phpMemoryLimit / 10), 100 * 1024));
  53. } else {
  54. // memory is unlimited, set to the default 10MB
  55. $this->streamChunkSize = static::DEFAULT_CHUNK_SIZE;
  56. }
  57. } else {
  58. // no memory limit information, set to the default 10MB
  59. $this->streamChunkSize = static::DEFAULT_CHUNK_SIZE;
  60. }
  61. if (\is_resource($stream)) {
  62. $this->stream = $stream;
  63. stream_set_chunk_size($this->stream, $this->streamChunkSize);
  64. } elseif (\is_string($stream)) {
  65. $this->url = Utils::canonicalizePath($stream);
  66. } else {
  67. throw new \InvalidArgumentException('A stream must either be a resource or a string.');
  68. }
  69. $this->fileOpenMode = $fileOpenMode;
  70. $this->filePermission = $filePermission;
  71. $this->useLocking = $useLocking;
  72. }
  73. /**
  74. * @inheritDoc
  75. */
  76. public function reset(): void
  77. {
  78. parent::reset();
  79. // auto-close on reset to make sure we periodically close the file in long running processes
  80. // as long as they correctly call reset() between jobs
  81. if ($this->url !== null && $this->url !== 'php://memory') {
  82. $this->close();
  83. }
  84. }
  85. /**
  86. * @inheritDoc
  87. */
  88. public function close(): void
  89. {
  90. if (null !== $this->url && \is_resource($this->stream)) {
  91. fclose($this->stream);
  92. }
  93. $this->stream = null;
  94. $this->dirCreated = null;
  95. }
  96. /**
  97. * Return the currently active stream if it is open
  98. *
  99. * @return resource|null
  100. */
  101. public function getStream()
  102. {
  103. return $this->stream;
  104. }
  105. /**
  106. * Return the stream URL if it was configured with a URL and not an active resource
  107. */
  108. public function getUrl(): ?string
  109. {
  110. return $this->url;
  111. }
  112. public function getStreamChunkSize(): int
  113. {
  114. return $this->streamChunkSize;
  115. }
  116. /**
  117. * @inheritDoc
  118. */
  119. protected function write(LogRecord $record): void
  120. {
  121. if ($this->hasUrlInodeWasChanged()) {
  122. $this->close();
  123. $this->write($record);
  124. return;
  125. }
  126. if (!\is_resource($this->stream)) {
  127. $url = $this->url;
  128. if (null === $url || '' === $url) {
  129. throw new \LogicException('Missing stream url, the stream can not be opened. This may be caused by a premature call to close().' . Utils::getRecordMessageForException($record));
  130. }
  131. $this->createDir($url);
  132. $this->errorMessage = null;
  133. // forwarding to $this->customErrorHandler using a closure to make the
  134. // private method accessible, see https://github.com/Seldaek/monolog/issues/1866
  135. set_error_handler(function (int $code, string $msg) {
  136. return $this->customErrorHandler($code, $msg);
  137. });
  138. try {
  139. $stream = fopen($url, $this->fileOpenMode);
  140. if ($this->filePermission !== null) {
  141. @chmod($url, $this->filePermission);
  142. }
  143. } finally {
  144. restore_error_handler();
  145. }
  146. if (!\is_resource($stream)) {
  147. $this->stream = null;
  148. throw new \UnexpectedValueException(sprintf('The stream or file "%s" could not be opened in append mode: '.$this->errorMessage, $url) . Utils::getRecordMessageForException($record));
  149. }
  150. stream_set_chunk_size($stream, $this->streamChunkSize);
  151. $this->stream = $stream;
  152. $this->inodeUrl = $this->getInodeFromUrl();
  153. }
  154. $stream = $this->stream;
  155. if ($this->useLocking) {
  156. // ignoring errors here, there's not much we can do about them
  157. flock($stream, LOCK_EX);
  158. }
  159. $this->errorMessage = null;
  160. // forwarding to $this->customErrorHandler using a closure to make the
  161. // private method accessible, see https://github.com/Seldaek/monolog/issues/1866
  162. set_error_handler(function (int $code, string $msg) {
  163. return $this->customErrorHandler($code, $msg);
  164. });
  165. try {
  166. $this->streamWrite($stream, $record);
  167. } finally {
  168. restore_error_handler();
  169. }
  170. if ($this->errorMessage !== null) {
  171. $error = $this->errorMessage;
  172. // close the resource if possible to reopen it, and retry the failed write
  173. if (!$this->retrying && $this->url !== null && $this->url !== 'php://memory') {
  174. $this->retrying = true;
  175. $this->close();
  176. $this->write($record);
  177. return;
  178. }
  179. throw new \UnexpectedValueException('Writing to the log file failed: '.$error . Utils::getRecordMessageForException($record));
  180. }
  181. $this->retrying = false;
  182. if ($this->useLocking) {
  183. flock($stream, LOCK_UN);
  184. }
  185. }
  186. /**
  187. * Write to stream
  188. * @param resource $stream
  189. */
  190. protected function streamWrite($stream, LogRecord $record): void
  191. {
  192. $data = (string) $record->formatted;
  193. $length = \strlen($data);
  194. $written = 0;
  195. // fwrite() may write only part of the data on non-blocking streams, so loop until it is all written, see https://github.com/Seldaek/monolog/issues/2011
  196. while ($written < $length) {
  197. $result = fwrite($stream, substr($data, $written));
  198. if ($result === false) {
  199. // write failed, let the customErrorHandler/errorMessage logic in write() report and retry it
  200. break;
  201. }
  202. if ($result === 0) {
  203. // the non-blocking stream's buffer is full, wait until it can be written to again instead of busy-looping or dropping the rest of the record
  204. $write = [$stream];
  205. $read = $except = [];
  206. stream_select($read, $write, $except, null);
  207. continue;
  208. }
  209. $written += $result;
  210. }
  211. }
  212. /**
  213. * @return true
  214. */
  215. private function customErrorHandler(int $code, string $msg): bool
  216. {
  217. $this->errorMessage = preg_replace('{^(fopen|mkdir|fwrite)\(.*?\): }', '', $msg);
  218. return true;
  219. }
  220. private function getDirFromStream(string $stream): ?string
  221. {
  222. $pos = strpos($stream, '://');
  223. if ($pos === false) {
  224. return \dirname($stream);
  225. }
  226. if ('file://' === substr($stream, 0, 7)) {
  227. return \dirname(substr($stream, 7));
  228. }
  229. return null;
  230. }
  231. private function createDir(string $url): void
  232. {
  233. // Do not try to create dir if it has already been tried.
  234. if (true === $this->dirCreated) {
  235. return;
  236. }
  237. $dir = $this->getDirFromStream($url);
  238. if (null !== $dir && !is_dir($dir)) {
  239. $this->errorMessage = null;
  240. // forwarding to $this->customErrorHandler using a closure to make the
  241. // private method accessible, see https://github.com/Seldaek/monolog/issues/1866
  242. set_error_handler(function (int $code, string $msg) {
  243. return $this->customErrorHandler($code, $msg);
  244. });
  245. $status = mkdir($dir, 0777, true);
  246. restore_error_handler();
  247. if (false === $status && !is_dir($dir) && strpos((string) $this->errorMessage, 'File exists') === false) {
  248. throw new \UnexpectedValueException(sprintf('There is no existing directory at "%s" and it could not be created: '.$this->errorMessage, $dir));
  249. }
  250. }
  251. $this->dirCreated = true;
  252. }
  253. private function getInodeFromUrl(): ?int
  254. {
  255. if ($this->url === null || str_starts_with($this->url, 'php://')) {
  256. return null;
  257. }
  258. $inode = @fileinode($this->url);
  259. return $inode === false ? null : $inode;
  260. }
  261. private function hasUrlInodeWasChanged(): bool
  262. {
  263. if ($this->inodeUrl === null || $this->retrying || $this->inodeUrl === $this->getInodeFromUrl()) {
  264. return false;
  265. }
  266. $this->retrying = true;
  267. return true;
  268. }
  269. }