Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
79.27% |
130 / 164 |
|
37.50% |
6 / 16 |
CRAP | |
0.00% |
0 / 3 |
| LineLocation | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| ReadResult | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| BaseLogsChannel | |
80.25% |
130 / 162 |
|
42.86% |
6 / 14 |
67.51 | |
0.00% |
0 / 1 |
| getId | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| getName | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| getLogFileBefore | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| getLogFileAfter | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| getLineLocationForDateTime | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| continueReading | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| readAroundDateTime | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| getOrCreateIndex | |
75.00% |
12 / 16 |
|
0.00% |
0 / 1 |
5.39 | |||
| indexFile | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
5 | |||
| readIndexFile | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| writeIndexFile | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| readMatchingLinesBefore | |
100.00% |
34 / 34 |
|
100.00% |
1 / 1 |
9 | |||
| readMatchingLinesAfter | |
94.29% |
33 / 35 |
|
0.00% |
0 / 1 |
9.02 | |||
| isLineMatching | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
3.21 | |||
| isLineMatchingMinLogLevel | |
18.18% |
2 / 11 |
|
0.00% |
0 / 1 |
7.93 | |||
| isLineMatchingTextSearch | |
50.00% |
2 / 4 |
|
0.00% |
0 / 1 |
2.50 | |||
| escapeSpecialChars | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| parseDateTimeOfLine | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
4.12 | |||
| getDateMaxPosition | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Olz\Apps\Logs\Utils; |
| 4 | |
| 5 | use Olz\Utils\WithUtilsTrait; |
| 6 | use Psr\Log\LogLevel; |
| 7 | |
| 8 | class LineLocation { |
| 9 | /** @param int<-1, 1> $comparison */ |
| 10 | public function __construct( |
| 11 | public LogFileInterface $logFile, |
| 12 | public int $lineNumber, // TODO still necessary? -1 = last line |
| 13 | public int $comparison, |
| 14 | ) { |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | class ReadResult { |
| 19 | /** @param array<string> $lines */ |
| 20 | public function __construct( |
| 21 | public array $lines, |
| 22 | public ?LineLocation $previous, |
| 23 | public ?LineLocation $next, |
| 24 | ) { |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | abstract class BaseLogsChannel { |
| 29 | use WithUtilsTrait; |
| 30 | |
| 31 | public const LOG_LEVELS = [ |
| 32 | LogLevel::DEBUG, |
| 33 | LogLevel::INFO, |
| 34 | LogLevel::NOTICE, |
| 35 | LogLevel::WARNING, |
| 36 | LogLevel::ERROR, |
| 37 | LogLevel::CRITICAL, |
| 38 | LogLevel::ALERT, |
| 39 | LogLevel::EMERGENCY, |
| 40 | ]; |
| 41 | |
| 42 | public const INDEX_FILE_VERSION = '1.1'; |
| 43 | |
| 44 | abstract public static function getId(): string; |
| 45 | |
| 46 | abstract public static function getName(): string; |
| 47 | |
| 48 | // Page size; number of lines of log entires. |
| 49 | public static int $pageSize = 1000; |
| 50 | |
| 51 | // Assumes there are files, though... |
| 52 | abstract protected function getLogFileBefore(LogFileInterface $log_file): LogFileInterface; |
| 53 | |
| 54 | abstract protected function getLogFileAfter(LogFileInterface $log_file): LogFileInterface; |
| 55 | |
| 56 | abstract protected function getLineLocationForDateTime( |
| 57 | \DateTime $date_time, |
| 58 | ): LineLocation; |
| 59 | |
| 60 | /** @param array{targetDate?: ?string, firstDate?: ?string, lastDate?: ?string, minLogLevel?: ?string, textSearch?: ?string, pageToken?: ?string} $query */ |
| 61 | public function continueReading( |
| 62 | LineLocation $line_location, |
| 63 | string $mode, |
| 64 | array $query, |
| 65 | ): ReadResult { |
| 66 | if ($mode === 'previous') { |
| 67 | $line_limit = self::$pageSize; |
| 68 | $time_limit = time() + 10; |
| 69 | return $this->readMatchingLinesBefore($line_location, $query, $line_limit, $time_limit); |
| 70 | } |
| 71 | if ($mode === 'next') { |
| 72 | $line_limit = self::$pageSize; |
| 73 | $time_limit = time() + 10; |
| 74 | return $this->readMatchingLinesAfter($line_location, $query, $line_limit, $time_limit); |
| 75 | } |
| 76 | throw new \Exception("Mode must be 'previous' or 'next', was '{$mode}'."); |
| 77 | } |
| 78 | |
| 79 | /** @param array{targetDate?: ?string, firstDate?: ?string, lastDate?: ?string, minLogLevel?: ?string, textSearch?: ?string, pageToken?: ?string} $query */ |
| 80 | public function readAroundDateTime(\DateTime $date_time, array $query): ReadResult { |
| 81 | $line_location = $this->getLineLocationForDateTime($date_time); |
| 82 | $line_limit = intval(self::$pageSize / 2); |
| 83 | $time_limit = time() + 5; |
| 84 | $lines_before = $this->readMatchingLinesBefore($line_location, $query, $line_limit, $time_limit); |
| 85 | $line_limit = self::$pageSize - count($lines_before->lines) + 1; |
| 86 | $time_limit = time() + 5; |
| 87 | $lines_after = $this->readMatchingLinesAfter($line_location, $query, $line_limit, $time_limit); |
| 88 | return new ReadResult([ |
| 89 | ...$lines_before->lines, |
| 90 | '---', |
| 91 | ...$lines_after->lines, |
| 92 | ], $lines_before->previous, $lines_after->next); |
| 93 | } |
| 94 | |
| 95 | /** @return array{version?: string, modified: int, start_date: ?string, lines: array<int>} */ |
| 96 | protected function getOrCreateIndex(LogFileInterface $log_file): array { |
| 97 | $index_path = $log_file->getIndexPath(); |
| 98 | if (is_file($index_path)) { |
| 99 | $index = $this->readIndexFile($index_path); |
| 100 | $version_matches = self::INDEX_FILE_VERSION === ($index['version'] ?? '1.0'); |
| 101 | $modified_matches = $log_file->modified() === $index['modified']; |
| 102 | $is_cache_hit = $version_matches && $modified_matches; |
| 103 | if ($is_cache_hit) { |
| 104 | return $index; |
| 105 | } |
| 106 | $this->log()->debug("Obsolete existing index {$index_path}"); |
| 107 | unlink($index_path); |
| 108 | } |
| 109 | // cache miss |
| 110 | $this->log()->debug("Create new index {$index_path}"); |
| 111 | $index = $this->indexFile($log_file); |
| 112 | try { |
| 113 | $this->writeIndexFile($index_path, $index); |
| 114 | } catch (\Throwable $th) { |
| 115 | $this->log()->warning("Failed to write index file {$index_path}"); |
| 116 | } |
| 117 | return $index; |
| 118 | } |
| 119 | |
| 120 | /** @return array{version?: string, modified: int, start_date: ?string, lines: array<int>} */ |
| 121 | protected function indexFile(LogFileInterface $log_file): array { |
| 122 | $index = []; |
| 123 | $index['version'] = self::INDEX_FILE_VERSION; |
| 124 | $index['modified'] = $log_file->modified(); |
| 125 | $index['start_date'] = null; |
| 126 | $index['lines'] = [0]; |
| 127 | $fp = $log_file->open('r'); |
| 128 | $log_file->seek($fp, 0, SEEK_END); |
| 129 | $file_size = $log_file->tell($fp); |
| 130 | $log_file->seek($fp, 0, SEEK_SET); |
| 131 | while (!$log_file->eof($fp)) { |
| 132 | $line = $log_file->gets($fp); |
| 133 | if ($index['start_date'] === null) { |
| 134 | $truncated_line = substr($line ?? '', 0, $this->getDateMaxPosition()); |
| 135 | $date_time = $this->parseDateTimeOfLine($truncated_line); |
| 136 | if ($date_time) { |
| 137 | $index['start_date'] = $date_time->format('Y-m-d H:i:s'); |
| 138 | } |
| 139 | } |
| 140 | $line_index = $log_file->tell($fp); |
| 141 | if ($line_index !== $file_size) { |
| 142 | $index['lines'][] = $line_index; |
| 143 | } |
| 144 | } |
| 145 | fclose($fp); |
| 146 | $index['lines'][] = $file_size; |
| 147 | return $index; |
| 148 | } |
| 149 | |
| 150 | /** @return array{version?: string, modified: int, start_date: ?string, lines: array<int>} */ |
| 151 | protected function readIndexFile(string $index_path): array { |
| 152 | // @phpstan-ignore-next-line |
| 153 | return json_decode(gzdecode(file_get_contents($index_path) ?: ''), true); |
| 154 | } |
| 155 | |
| 156 | /** @param array{version?: string, modified: int, start_date: ?string, lines: array<int>} $content */ |
| 157 | protected function writeIndexFile(string $index_path, array $content): void { |
| 158 | file_put_contents($index_path, gzencode(json_encode($content) ?: '{}')); |
| 159 | } |
| 160 | |
| 161 | /** @param array{targetDate?: ?string, firstDate?: ?string, lastDate?: ?string, minLogLevel?: ?string, textSearch?: ?string, pageToken?: ?string} $query */ |
| 162 | protected function readMatchingLinesBefore( |
| 163 | LineLocation $line_location, |
| 164 | array $query, |
| 165 | int $line_limit, |
| 166 | float $time_limit, |
| 167 | ): ReadResult { |
| 168 | $log_file = $line_location->logFile; |
| 169 | $file_index = $this->getOrCreateIndex($log_file); |
| 170 | $fp = $log_file->open('r'); |
| 171 | |
| 172 | $continuation_location = clone $line_location; |
| 173 | if ($continuation_location->lineNumber === -1) { |
| 174 | // last line is empty |
| 175 | $continuation_location->lineNumber = count($file_index['lines']) - 2; |
| 176 | } |
| 177 | if ($continuation_location->comparison <= 0) { |
| 178 | $continuation_location->lineNumber--; |
| 179 | } |
| 180 | $matching_lines = []; |
| 181 | while (count($matching_lines) < $line_limit && time() <= $time_limit) { |
| 182 | if ($continuation_location->lineNumber >= 0) { |
| 183 | $index = $file_index['lines'][$continuation_location->lineNumber]; |
| 184 | $log_file->seek($fp, $index); |
| 185 | $line = $this->escapeSpecialChars($log_file->gets($fp)); |
| 186 | if ($this->isLineMatching($line, $query)) { |
| 187 | array_unshift($matching_lines, $line); |
| 188 | } |
| 189 | $continuation_location->lineNumber--; |
| 190 | } |
| 191 | $continuation_location->comparison = -1; |
| 192 | if ($continuation_location->lineNumber < 0) { |
| 193 | try { |
| 194 | $log_file_before = $this->getLogFileBefore($log_file); |
| 195 | $this->log()->debug("log_file_before {$log_file_before->getPath()}"); |
| 196 | $prev_file_location = new LineLocation($log_file_before, -1, 1); |
| 197 | $new_line_limit = $line_limit - count($matching_lines); |
| 198 | $result = $this->readMatchingLinesBefore($prev_file_location, $query, $new_line_limit, $time_limit); |
| 199 | $matching_lines = [ |
| 200 | ...$result->lines, |
| 201 | ...$matching_lines, |
| 202 | ]; |
| 203 | $continuation_location = $result->previous; |
| 204 | } catch (\Throwable $th) { |
| 205 | // Then, that's all we can do |
| 206 | $continuation_location = null; |
| 207 | } |
| 208 | break; |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | $log_file->close($fp); |
| 213 | return new ReadResult($matching_lines, $continuation_location, $line_location); |
| 214 | } |
| 215 | |
| 216 | /** @param array{targetDate?: ?string, firstDate?: ?string, lastDate?: ?string, minLogLevel?: ?string, textSearch?: ?string, pageToken?: ?string} $query */ |
| 217 | protected function readMatchingLinesAfter( |
| 218 | LineLocation $line_location, |
| 219 | array $query, |
| 220 | int $line_limit, |
| 221 | float $time_limit, |
| 222 | ): ReadResult { |
| 223 | $log_file = $line_location->logFile; |
| 224 | $file_index = $this->getOrCreateIndex($log_file); |
| 225 | $fp = $log_file->open('r'); |
| 226 | // last line is empty |
| 227 | $number_of_lines = count($file_index['lines']) - 1; |
| 228 | |
| 229 | $continuation_location = clone $line_location; |
| 230 | if ($continuation_location->lineNumber === -1) { |
| 231 | // last line is empty |
| 232 | $continuation_location->lineNumber = count($file_index['lines']) - 2; |
| 233 | } |
| 234 | if ($continuation_location->comparison > 0) { |
| 235 | $continuation_location->lineNumber++; |
| 236 | } |
| 237 | $matching_lines = []; |
| 238 | while (count($matching_lines) < $line_limit && time() <= $time_limit) { |
| 239 | if ($continuation_location->lineNumber < $number_of_lines) { |
| 240 | $index = $file_index['lines'][$continuation_location->lineNumber]; |
| 241 | $log_file->seek($fp, $index); |
| 242 | $line = $this->escapeSpecialChars($log_file->gets($fp)); |
| 243 | if ($this->isLineMatching($line, $query)) { |
| 244 | array_push($matching_lines, $line); |
| 245 | } |
| 246 | $continuation_location->lineNumber++; |
| 247 | } |
| 248 | $continuation_location->comparison = 1; |
| 249 | if ($continuation_location->lineNumber >= $number_of_lines) { |
| 250 | try { |
| 251 | $log_file_after = $this->getLogFileAfter($log_file); |
| 252 | $this->log()->debug("log_file_after {$log_file_after->getPath()}"); |
| 253 | $next_file_location = new LineLocation($log_file_after, 0, -1); |
| 254 | $new_line_limit = $line_limit - count($matching_lines); |
| 255 | $result = $this->readMatchingLinesAfter($next_file_location, $query, $new_line_limit, $time_limit); |
| 256 | $matching_lines = [ |
| 257 | ...$matching_lines, |
| 258 | ...$result->lines, |
| 259 | ]; |
| 260 | $continuation_location = $result->next; |
| 261 | } catch (\Throwable $th) { |
| 262 | // Then, that's all we can do |
| 263 | $continuation_location = null; |
| 264 | } |
| 265 | break; |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | $log_file->close($fp); |
| 270 | return new ReadResult($matching_lines, $line_location, $continuation_location); |
| 271 | } |
| 272 | |
| 273 | /** @param array{targetDate?: ?string, firstDate?: ?string, lastDate?: ?string, minLogLevel?: ?string, textSearch?: ?string, pageToken?: ?string} $query */ |
| 274 | protected function isLineMatching(string $line, array $query): bool { |
| 275 | $min_log_level = $query['minLogLevel'] ?? null; |
| 276 | if (!$this->isLineMatchingMinLogLevel($line, $min_log_level)) { |
| 277 | return false; |
| 278 | } |
| 279 | $text_search = $query['textSearch'] ?? null; |
| 280 | if (!$this->isLineMatchingTextSearch($line, $text_search)) { |
| 281 | return false; |
| 282 | } |
| 283 | return true; |
| 284 | } |
| 285 | |
| 286 | protected function isLineMatchingMinLogLevel(string $line, ?string $min_log_level): bool { |
| 287 | if (!$min_log_level) { |
| 288 | return true; |
| 289 | } |
| 290 | $log_levels = self::LOG_LEVELS; |
| 291 | $level_pos = array_search($min_log_level, $log_levels); |
| 292 | if ($level_pos === false) { |
| 293 | return true; |
| 294 | } |
| 295 | $matching_log_levels = array_slice($log_levels, $level_pos); |
| 296 | $log_levels_regex = implode('|', array_map(function ($log_level) { |
| 297 | return '\.'.strtoupper($log_level); |
| 298 | }, $matching_log_levels)); |
| 299 | return (bool) preg_match("/{$log_levels_regex}/", $line); |
| 300 | } |
| 301 | |
| 302 | protected function isLineMatchingTextSearch(string $line, ?string $text_search): bool { |
| 303 | if (!$text_search) { |
| 304 | return true; |
| 305 | } |
| 306 | $esc_text_search = preg_quote($text_search, '/'); |
| 307 | return (bool) preg_match("/{$esc_text_search}/i", $line); |
| 308 | } |
| 309 | |
| 310 | protected function escapeSpecialChars(?string $line): string { |
| 311 | $line = iconv('UTF-8', "UTF-8//IGNORE", $line ?? ''); |
| 312 | $this->generalUtils()->checkNotBool($line, 'BaseLogsChannel::escapeSpecialChars iconv failed'); |
| 313 | return html_entity_decode(htmlspecialchars($line)); |
| 314 | } |
| 315 | |
| 316 | // Override this function, if you have a different date format. |
| 317 | protected function parseDateTimeOfLine(string $line): ?\DateTime { |
| 318 | $res = preg_match('/(\d{4}\-\d{2}\-\d{2})(T|\s+)(\d{2}\:\d{2}\:\d{2})/', $line, $matches); |
| 319 | if (!$res) { |
| 320 | return null; |
| 321 | } |
| 322 | try { |
| 323 | return new \DateTime("{$matches[1]} {$matches[3]}"); |
| 324 | } catch (\Throwable $th) { |
| 325 | return null; |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | // Override this function, if you have a different date placement within the log line. |
| 330 | protected function getDateMaxPosition(): int { |
| 331 | return 22; |
| 332 | } |
| 333 | } |