Completed
Pull Request — master (#4)
by Rasmus
07:12
created

ChromeLogger::createUniqueFilename()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
1
<?php
2
3
namespace Kodus\Logging;
4
5
use DateTimeInterface;
6
use Error;
7
use Exception;
8
use function file_put_contents;
9
use InvalidArgumentException;
10
use JsonSerializable;
11
use Psr\Http\Message\ResponseInterface;
12
use Psr\Log\AbstractLogger;
13
use Psr\Log\LoggerInterface;
14
use Psr\Log\LogLevel;
15
use ReflectionClass;
16
use ReflectionProperty;
17
use RuntimeException;
18
use Throwable;
19
20
/**
21
 * PSR-3 and PSR-7 compliant alternative to the original ChromeLogger by Craig Campbell.
22
 *
23
 * @link https://craig.is/writing/chrome-logger
24
 */
25
class ChromeLogger extends AbstractLogger implements LoggerInterface
26
{
27
    const VERSION = "4.1.0";
28
29
    const COLUMN_LOG       = "log";
30
    const COLUMN_BACKTRACE = "backtrace";
31
    const COLUMN_TYPE      = "type";
32
33
    const CLASS_NAME  = "type";
34
    const TABLES      = "tables";
35
    const HEADER_NAME = "X-ChromeLogger-Data";
36
    const LOCATION_HEADER_NAME = "X-ServerLog-Location";
37
38
    const LOG   = "log";
39
    const WARN  = "warn";
40
    const ERROR = "error";
41
    const INFO  = "info";
42
43
    const GROUP           = "group";
44
    const GROUP_END       = "groupEnd";
45
    const GROUP_COLLAPSED = "groupCollapsed";
46
    const TABLE           = "table";
47
48
    const DATETIME_FORMAT = "Y-m-d\\TH:i:s\\Z"; // ISO-8601 UTC date/time format
49
50
    const LIMIT_WARNING = "Beginning of log entries omitted - total header size over Chrome's internal limit!";
51
52
    /**
53
     * @var int header size limit (in bytes, defaults to 240KB)
54
     */
55
    protected $limit = 245760;
56
57
    /**
58
     * @var LogEntry[]
59
     */
60
    protected $entries = [];
61
62
    /**
63
     * @var string|null
64
     */
65
    private $local_path;
66
67
    /**
68
     * @var string|null
69
     */
70
    private $public_path;
71
72
    /**
73
     * Logs with an arbitrary level.
74
     *
75
     * @param mixed  $level
76
     * @param string $message
77
     * @param array  $context
78
     *
79
     * @return void
80
     */
81
    public function log($level, $message, array $context = [])
82
    {
83
        $this->entries[] = new LogEntry($level, $message, $context);
84
    }
85
86
    /**
87
     * Allows you to override the internal 240 KB header size limit.
88
     *
89
     * (Chrome has a 250 KB limit for the total size of all headers.)
90
     *
91
     * @see https://cs.chromium.org/chromium/src/net/http/http_stream_parser.h?q=ERR_RESPONSE_HEADERS_TOO_BIG&sq=package:chromium&dr=C&l=159
92
     *
93
     * @param int $limit header size limit (in bytes)
94
     */
95
    public function setLimit($limit)
96
    {
97
        $this->limit = $limit;
98
    }
99
100
    /**
101
     * @return int header size limit (in bytes)
102
     */
103
    public function getLimit()
104
    {
105
        return $this->limit;
106
    }
107
108
    /**
109
     * Enables persistence to local log-files served from your web-root.
110
     *
111
     * Bypasses the header-size limitation (imposed by Chrome, NGINX, etc.) by avoiding the
112
     * large `X-ChromeLogger-Data` header and instead storing the log in a flat file.
113
     *
114
     * Requires the [ServerLog](https://github.com/mindplay-dk/server-log) Chrome extension,
115
     * which replaces the ChromeLogger extension - this does NOT work with the regular
116
     * ChromeLogger extension.
117
     *
118
     * @link https://github.com/mindplay-dk/server-log
119
     *
120
     * @param string $local_path  absolute local path to a dedicated log-folder in your public web-root,
121
     *                            e.g. "/var/www/mysite.com/webroot/log"
122
     * @param string $public_path absolute public path, e.g. "/log"
123
     */
124
    public function usePersistence(string $local_path, string $public_path)
125
    {
126
        if (! is_dir($local_path)) {
127
            throw new InvalidArgumentException("local path does not exist: {$local_path}");
128
        }
129
130
        $this->local_path = $local_path;
131
        $this->public_path = $public_path;
132
    }
133
134
    /**
135
     * Adds headers for recorded log-entries in the ChromeLogger format, and clear the internal log-buffer.
136
     *
137
     * (You should call this at the end of the request/response cycle in your PSR-7 project, e.g.
138
     * immediately before emitting the Response.)
139
     *
140
     * @param ResponseInterface $response
141
     *
142
     * @return ResponseInterface
143
     */
144
    public function writeToResponse(ResponseInterface $response)
145
    {
146
        if ($this->local_path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->local_path of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
147
            $response = $response->withHeader(self::LOCATION_HEADER_NAME, $this->createLogFile());
148
        } else {
149
            $response = $response->withHeader(self::HEADER_NAME, $this->getHeaderValue());
150
        }
151
152
        $this->entries = [];
153
154
        return $response;
155
    }
156
157
    /**
158
     * Emit the header for recorded log-entries directly using `header()`, and clear the internal buffer.
159
     *
160
     * (You can use this in a non-PSR-7 project, immediately before you start emitting the response body.)
161
     *
162
     * @throws RuntimeException if you've already started emitting the response body
163
     *
164
     * @return void
165
     */
166
    public function emitHeader()
167
    {
168
        if (headers_sent()) {
169
            throw new RuntimeException("unable to emit ChromeLogger header: headers have already been sent");
170
        }
171
172
        if ($this->local_path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->local_path of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
173
            header(self::LOCATION_HEADER_NAME . ": " . $this->createLogFile());
174
        } else {
175
            header(self::HEADER_NAME . ": " . $this->getHeaderValue());
176
        }
177
178
        $this->entries = [];
179
    }
180
181
    /**
182
     * @return string raw value for the X-ChromeLogger-Data header
183
     */
184
    protected function getHeaderValue()
185
    {
186
        $data = $this->createData($this->entries);
187
188
        $value = $this->encodeData($data);
189
190
        if (strlen($value) > $this->limit) {
191
            $data["rows"][] = $this->createEntryData(new LogEntry(LogLevel::WARNING, self::LIMIT_WARNING))[0];
192
193
            // NOTE: the strategy here is to calculate an estimated overhead, based on the number
194
            //       of rows - because the size of each row may vary, this isn't necessarily accurate,
195
            //       so we may need repeat this more than once.
196
197
            while (strlen($value) > $this->limit) {
198
                $num_rows = count($data["rows"]); // current number of rows
199
200
                $row_size = strlen($value) / $num_rows; // average row-size
201
202
                $max_rows = (int) floor(($this->limit * 0.95) / $row_size); // 5% under the likely max. number of rows
203
204
                $excess = max(1, $num_rows - $max_rows); // remove at least 1 row
205
206
                $data["rows"] = array_slice($data["rows"], $excess); // remove excess rows
207
208
                $value = $this->encodeData($data); // encode again with fewer rows
209
            }
210
        }
211
212
        return $value;
213
    }
214
215
    /**
216
     * @return string public path to log-file
217
     */
218
    protected function createLogFile(): string
219
    {
220
        $this->collectGarbage();
221
222
        $content = json_encode(
223
            $this->createData($this->entries),
224
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
225
        );
226
227
        $filename = $this->createUniqueFilename();
228
229
        $local_path = "{$this->local_path}/{$filename}";
230
231
        if (@file_put_contents($local_path, $content) === false) {
232
            throw new RuntimeException("unable to write log-file: {$local_path}");
233
        }
234
235
        return "{$this->public_path}/{$filename}";
236
    }
237
238
    /**
239
     * @return string pseudo-random log filename
240
     */
241
    protected function createUniqueFilename(): string
242
    {
243
        return uniqid("log-", true) . ".json";
244
    }
245
246
    /**
247
     * Garbage-collects log-files older than one minute.
248
     */
249
    protected function collectGarbage()
250
    {
251
        foreach (glob("{$this->local_path}/*.json") as $path) {
252
            $age = $this->getTime() - filemtime($path);
253
254
            if ($age > 60) {
255
                @unlink($path);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
256
            }
257
        }
258
    }
259
260
    /**
261
     * @return int
262
     */
263
    protected function getTime(): int
264
    {
265
        return time();
266
    }
267
268
    /**
269
     * Encodes the ChromeLogger-compatible data-structure in JSON/base64-format
270
     *
271
     * @param array $data header data
272
     *
273
     * @return string
274
     */
275
    protected function encodeData(array $data)
276
    {
277
        $json = json_encode(
278
            $data,
279
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
280
        );
281
282
        $value = base64_encode($json);
283
284
        return $value;
285
    }
286
287
    /**
288
     * Internally builds the ChromeLogger-compatible data-structure from internal log-entries.
289
     *
290
     * @param LogEntry[] $entries
291
     *
292
     * @return array
293
     */
294
    protected function createData(array $entries)
295
    {
296
        $rows = [];
297
298
        foreach ($entries as $entry) {
299
            foreach ($this->createEntryData($entry) as $row) {
300
                $rows[] = $row;
301
            }
302
        }
303
304
        return [
305
            "version" => self::VERSION,
306
            "columns" => [self::COLUMN_LOG, self::COLUMN_TYPE, self::COLUMN_BACKTRACE],
307
            "rows"    => $rows,
308
        ];
309
    }
310
311
    /**
312
     * Encode an individual LogEntry in ChromeLogger-compatible format
313
     *
314
     * @param LogEntry $entry
315
     *
316
     * @return array log-entries in ChromeLogger row-format
317
     */
318
    protected function createEntryData(LogEntry $entry)
319
    {
320
        // NOTE: "log" level type is deliberately omitted from the following map, since
321
        //       it's the default entry-type in ChromeLogger, and can be omitted.
322
323
        static $LEVELS = [
324
            LogLevel::DEBUG     => self::LOG,
325
            LogLevel::INFO      => self::INFO,
326
            LogLevel::NOTICE    => self::INFO,
327
            LogLevel::WARNING   => self::WARN,
328
            LogLevel::ERROR     => self::ERROR,
329
            LogLevel::CRITICAL  => self::ERROR,
330
            LogLevel::ALERT     => self::ERROR,
331
            LogLevel::EMERGENCY => self::ERROR,
332
        ];
333
334
        $rows = []; // all rows returned by this function
335
336
        $row = []; // the first row returned by this function
337
338
        $message = $entry->message;
339
        $context = $entry->context;
340
341
        if (isset($context["exception"])) {
342
            // NOTE: per PSR-3, this reserved key could be anything, but if it is an Exception, we
343
            //       can use that Exception to obtain a stack-trace for output in ChromeLogger.
344
345
            $exception = $context["exception"];
346
347
            if ($exception instanceof Exception || $exception instanceof Error) {
348
                $stack_trace = explode("\n", $exception->__toString());
349
                $title = array_shift($stack_trace);
350
351
                $rows[] = [[$title], self::GROUP_COLLAPSED];
352
                $rows[] = [[implode("\n", $stack_trace)], self::INFO];
353
                $rows[] = [[], self::GROUP_END];
354
            }
355
356
            unset($context["exception"]);
357
        }
358
359
        $data = [str_replace("%", "%%", $message)];
360
361
        foreach ($context as $key => $value) {
362
            if (is_array($context[$key]) && preg_match("/^table:\\s*(.+)/ui", $key, $matches) === 1) {
363
                $title = $matches[1];
364
365
                $rows[] = [[$title], self::GROUP_COLLAPSED];
366
                $rows[] = [[$context[$key]], self::TABLE];
367
                $rows[] = [[], self::GROUP_END];
368
369
                unset($context[$key]);
370
            } else {
371
                if (!is_int($key)) {
372
                    $data[] = "{$key}:";
373
                }
374
375
                $data[] = $this->sanitize($value);
376
            }
377
        }
378
379
        $row[] = $data;
380
381
        $row[] = isset($LEVELS[$entry->level])
382
            ? $LEVELS[$entry->level]
383
            : self::LOG;
384
385
        // Optimization: ChromeLogger defaults to "log" if no entry-type is specified.
386
387
        if ($row[1] === self::LOG) {
388
            if (count($row) === 2) {
389
                unset($row[1]);
390
            } else {
391
                $row[1] = "";
392
            }
393
        }
394
395
        array_unshift($rows, $row); // append the first row
396
397
        return $rows;
398
    }
399
400
    /**
401
     * Internally marshall and sanitize context values, producing a JSON-compatible data-structure.
402
     *
403
     * @param mixed  $data      any PHP object, array or value
404
     * @param true[] $processed map where SPL object-hash => TRUE (eliminates duplicate objects from data-structures)
405
     *
406
     * @return mixed marshalled and sanitized context
407
     */
408
    protected function sanitize($data, &$processed = [])
409
    {
410
        if (is_array($data)) {
411
            /**
412
             * @var array $data
413
             */
414
415
            foreach ($data as $name => $value) {
416
                $data[$name] = $this->sanitize($value, $processed);
417
            }
418
419
            return $data;
420
        }
421
422
        if (is_object($data)) {
423
            /**
424
             * @var object $data
425
             */
426
427
            $class_name = get_class($data);
428
429
            $hash = spl_object_hash($data);
430
431
            if (isset($processed[$hash])) {
432
                // NOTE: duplicate objects (circular references) are omitted to prevent recursion.
433
434
                return [self::CLASS_NAME => $class_name];
435
            }
436
437
            $processed[$hash] = true;
438
439
            if ($data instanceof JsonSerializable) {
0 ignored issues
show
Bug introduced by
The class JsonSerializable does not exist. Is this class maybe located in a folder that is not analyzed, or in a newer version of your dependencies than listed in your composer.lock/composer.json?
Loading history...
440
                // NOTE: this doesn't serialize to JSON, it only marshalls to a JSON-compatible data-structure
441
442
                $data = $this->sanitize($data->jsonSerialize(), $processed);
0 ignored issues
show
Documentation introduced by
$processed is of type array<integer|string,obj...\Logging\true>|boolean>, but the function expects a array<integer,object<Kodus\Logging\true>>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
443
            } elseif ($data instanceof DateTimeInterface) {
444
                $data = $this->extractDateTimeProperties($data);
445
            } elseif ($data instanceof Exception || $data instanceof Error) {
446
                $data = $this->extractExceptionProperties($data);
447
            } else {
448
                $data = $this->sanitize($this->extractObjectProperties($data), $processed);
0 ignored issues
show
Documentation introduced by
$processed is of type array<integer|string,obj...\Logging\true>|boolean>, but the function expects a array<integer,object<Kodus\Logging\true>>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
449
            }
450
451
            return array_merge([self::CLASS_NAME => $class_name], $data);
452
        }
453
454
        if (is_scalar($data)) {
455
            return $data; // bool, int, float
456
        }
457
458
        if (is_resource($data)) {
459
            $resource = explode("#", (string) $data);
460
461
            return [
462
                self::CLASS_NAME => "resource<" . get_resource_type($data) . ">",
463
                "id" => array_pop($resource)
464
            ];
465
        }
466
467
        return null; // omit any other unsupported types (e.g. resource handles)
468
    }
469
470
    /**
471
     * @param DateTimeInterface $datetime
472
     *
473
     * @return array
474
     */
475
    protected function extractDateTimeProperties(DateTimeInterface $datetime)
476
    {
477
        $utc = date_create_from_format("U", $datetime->format("U"), timezone_open("UTC"));
478
479
        return [
480
            "datetime" => $utc->format(self::DATETIME_FORMAT),
481
            "timezone" => $datetime->getTimezone()->getName(),
482
        ];
483
    }
484
485
    /**
486
     * @param object $object
487
     *
488
     * @return array
489
     */
490
    protected function extractObjectProperties($object)
491
    {
492
        $properties = [];
493
494
        $reflection = new ReflectionClass(get_class($object));
495
496
        // obtain public, protected and private properties of the class itself:
497
498 View Code Duplication
        foreach ($reflection->getProperties() as $property) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
499
            if ($property->isStatic()) {
500
                continue; // omit static properties
501
            }
502
503
            $property->setAccessible(true);
504
505
            $properties["\${$property->name}"] = $property->getValue($object);
506
        }
507
508
        // obtain any inherited private properties from parent classes:
509
510
        while ($reflection = $reflection->getParentClass()) {
511 View Code Duplication
            foreach ($reflection->getProperties(ReflectionProperty::IS_PRIVATE) as $property) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
512
                $property->setAccessible(true);
513
514
                $properties["{$reflection->name}::\${$property->name}"] = $property->getValue($object);
515
            }
516
        }
517
518
        return $properties;
519
    }
520
521
    /**
522
     * @param Throwable $exception
523
     *
524
     * @return array
525
     */
526
    protected function extractExceptionProperties($exception)
527
    {
528
        $previous = $exception->getPrevious();
529
530
        return [
531
            "\$message"  => $exception->getMessage(),
532
            "\$file"     => $exception->getFile(),
533
            "\$code"     => $exception->getCode(),
534
            "\$line"     => $exception->getLine(),
535
            "\$previous" => $previous ? $this->extractExceptionProperties($previous) : null,
536
        ];
537
    }
538
}
539