Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
27.68% covered (danger)
27.68%
49 / 177
21.05% covered (danger)
21.05%
4 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
API
27.68% covered (danger)
27.68%
49 / 177
21.05% covered (danger)
21.05%
4 / 19
2500.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_common_context
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_common_context
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_log_url
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 get_log_files
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
156
 delete_log
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 delete_all_logs
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 delete_old_logs
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 get_backtrace
82.61% covered (warning)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
22.10
 set_cached_backtrace
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_cached_backtrace
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 is_backtrace_contains_plugin
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 is_file_from_plugin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_last_log_time_transient_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_last_log_time
82.61% covered (warning)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
7.26
 get_last_logs_view_time
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 set_last_logs_view_time
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 parse_log
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 log_lines_to_entry
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2/**
3 * The main functions of the logger.
4 *
5 * @package brianhenryie/bh-wp-logger
6 */
7
8namespace BrianHenryIE\WP_Logger\API;
9
10use BrianHenryIE\WC_Logger\WC_PSR_Logger;
11use BrianHenryIE\WP_Logger\Admin\Logs_List_Table;
12use BrianHenryIE\WP_Logger\Admin\Logs_Page;
13use BrianHenryIE\WP_Logger\API_Interface;
14use BrianHenryIE\WP_Logger\Logger_Settings_Interface;
15use BrianHenryIE\WP_Logger\Logger;
16use BrianHenryIE\WP_Logger\WooCommerce_Logger_Settings_Interface;
17use DateTime;
18use DateTimeImmutable;
19use DateTimeInterface;
20use DateTimeZone;
21use Exception;
22use Psr\Log\LoggerAwareTrait;
23use Psr\Log\LoggerInterface;
24use Psr\Log\NullLogger;
25
26/**
27 * BH_WP_PSR_Logger extends this, then Logger extends that.
28 *
29 * @see BH_WP_PSR_Logger
30 * @see Logger
31 */
32class API implements API_Interface {
33
34    use LoggerAwareTrait;
35
36    const CACHE_GROUP_KEY = 'bh-wp-logger';
37
38    /**
39     * Needed for the plugin slug to link correctly.
40     *
41     * @uses Logger_Settings_Interface::get_plugin_slug()
42     *
43     * @var Logger_Settings_Interface
44     */
45    protected Logger_Settings_Interface $settings;
46
47    /**
48     *
49     * TODO: IS getmypid() reliable?
50     * TODO: Add current user id.
51     *
52     * @see https://stackoverflow.com/questions/10404979/get-unique-worker-thread-process-request-id-in-php
53     *
54     * @var string[] Common data for context. "state"?
55     */
56    protected $common_context = array();
57
58    /**
59     * Instantiate the API class with settings provided by the plugin.
60     *
61     * BH_WP_PSR_Logger needs and API instance and API needs a PSR logger instance if it is to log. LoggerAwareTrait
62     * allows setting the logger after instantiation.
63     *
64     * @param Logger_Settings_Interface $settings The settings provided by the plugin to instantiate the logger.
65     * @param ?LoggerInterface          $logger A PSR logger, presumably later a BH_WP_PSR_Logger.
66     */
67    public function __construct( Logger_Settings_Interface $settings, ?LoggerInterface $logger = null ) {
68        $this->setLogger( $logger ?? new NullLogger() );
69        $this->settings = $settings;
70    }
71
72    /**
73     * Array of data to add to every log entry.
74     * Resets on each new pageload (request).
75     *
76     * @return string[]
77     */
78    public function get_common_context(): array {
79        return $this->common_context;
80    }
81
82    /**
83     * Set a value in the common context.
84     *
85     * @param string $key Descriptive key.
86     * @param mixed  $value Will be parsed to JSON later.
87     *
88     * @return void
89     */
90    public function set_common_context( string $key, $value ): void {
91        $this->common_context[ $key ] = $value;
92    }
93
94    /**
95     * Get the WordPress admin link to the log UI at a date.
96     *
97     * @param ?string $date A date string Y-m-d or null to get the most recent.
98     *
99     * @return string
100     */
101    public function get_log_url( ?string $date = null ): string {
102
103        $query_args = array(
104            'page' => $this->settings->get_plugin_slug() . '-logs',
105        );
106
107        if ( ! empty( $date ) ) {
108            $query_args['date'] = $date;
109        }
110
111        $logs_url = admin_url( add_query_arg( $query_args, 'admin.php' ) );
112
113        return $logs_url;
114    }
115
116    /**
117     * Scan the logs files dir for the latest log file, or the log file matching the supplied date.
118     *
119     * TODO: Test the regex. It seems to be pulling in all files that match a date?
120     *
121     * @param ?string $date In 'Y-m-d' format. e.g. '2021-09-16'.
122     *
123     * @return array<string, string> Y-m-d index with path as the value.
124     */
125    public function get_log_files( ?string $date = null ): array {
126
127        // TODO: Replace WC_LOG_DIR check with the plugin_active check that is used overall.
128        if ( ( $this->settings instanceof WooCommerce_Logger_Settings_Interface ) && defined( 'WC_LOG_DIR' ) ) {
129
130            $log_files_dir = WC_LOG_DIR;
131
132        } else {
133
134            $log_files_dir = wp_normalize_path( WP_CONTENT_DIR . '/uploads/logs/' );
135        }
136
137        $files      = scandir( $log_files_dir );
138        $logs_files = array();
139
140        if ( ! empty( $files ) ) {
141            foreach ( $files as $filename ) {
142                if ( ! in_array( $filename, array( '.', '..' ), true ) ) {
143
144                    if ( ! is_dir( $filename ) && strstr( $filename, '.log' ) ) {
145
146                        if ( 1 === preg_match( '/^' . $this->settings->get_plugin_slug() . '-(\d{4}-\d{2}-\d{2}).*/', $filename, $regex_matches ) ) {
147                            $logs_files[ "{$regex_matches[1]}" ] = $log_files_dir . $filename;
148
149                            if ( ! is_null( $date ) && $regex_matches[1] === $date ) {
150                                $path     = $log_files_dir . $filename;
151                                $realpath = realpath( $path );
152                                return array( $date => false === $realpath ? $path : $realpath );
153                            }
154                        }
155                    }
156                }
157            }
158        }
159
160        ksort( $logs_files );
161
162        return $logs_files;
163    }
164
165    /**
166     * Delete a specific date's log file.
167     *
168     * @param string $ymd_date The date formatted Y-m-d, e.g. 2021-09-27.
169     *
170     * @used-by Logs_Page
171     *
172     * @return array{success:bool, message?:string}
173     */
174    public function delete_log( string $ymd_date ): array {
175
176        $result = array();
177
178        $log_filepaths_by_date = $this->get_log_files();
179
180        if ( ! isset( $log_filepaths_by_date[ $ymd_date ] ) ) {
181            $result['success'] = false;
182            $message           = 'Log file not found for date: ' . $ymd_date;
183            $result['message'] = $message;
184            $this->logger->warning( $message );
185            return $result;
186        }
187
188        unlink( $log_filepaths_by_date[ $ymd_date ] );
189        $result['success'] = true;
190        $message           = 'Logfile deleted at ' . $log_filepaths_by_date[ $ymd_date ];
191        $result['message'] = $message;
192        $this->logger->info( $message, array( 'logfile' => $log_filepaths_by_date[ $ymd_date ] ) );
193
194        return $result;
195    }
196
197    /**
198     * Delete all logs for this plugin.
199     *
200     * @used-by Logs_Page
201     *
202     * @return array{success:bool, deleted_files:array<string>, failed_to_delete:array<string>}
203     */
204    public function delete_all_logs(): array {
205
206        $result = array();
207
208        $deleted_files    = array();
209        $failed_to_delete = array();
210
211        $log_filepaths_by_date = $this->get_log_files();
212
213        foreach ( $log_filepaths_by_date as $date => $log_filepath ) {
214            $deleted = unlink( $log_filepath );
215            if ( $deleted ) {
216                $deleted_files[ $date ] = $log_filepath;
217            } else {
218                $failed_to_delete[ $date ] = $log_filepath;
219            }
220        }
221
222        $result['deleted_files']    = $deleted_files;
223        $result['failed_to_delete'] = $failed_to_delete;
224        $result['success']          = empty( $failed_to_delete );
225
226        return $result;
227    }
228
229    /**
230     * Deletes log files older than MONTH_IN_SECONDS.
231     * Deletes empty log files.
232     *
233     * TODO: Do not use for WooCommerce_Logger_Interface, because WooCommerce handles deleting logs itself.
234     *
235     * @used-by Cron::delete_old_logs()
236     */
237    public function delete_old_logs(): void {
238
239        $existing_logs = $this->get_log_files();
240
241        foreach ( $existing_logs as $date => $log_filepath ) {
242
243            if ( strtotime( $date ) < time() - MONTH_IN_SECONDS ) {
244                $this->logger->debug( 'deleting old log file ' . $log_filepath );
245                unlink( $log_filepath );
246            } elseif ( 0 === filesize( $log_filepath ) ) {
247                $this->logger->debug( 'deleting empty log file ' . $log_filepath );
248                unlink( $log_filepath );
249            }
250        }
251
252        // TODO: delete the last visited option if it's older than the most recent logs.
253    }
254
255
256    /**
257     * Get the backtrace and skips:
258     * * function calls from inside this file
259     * * call_user_func_array()
260     * * function calls from other plugins using this same library (using filename).
261     *
262     * TODO: Check is WordPress's own backtrace a good replacement for Spatie.
263     *
264     * @param ?string $source_hash A unique identifier for the source of the log entry. Used to cache the backtrace. The backtrace will not be cached if this is absent.
265     * @param ?int    $steps The number of backtrace entries to return.
266     *
267     * @return array<array{file?:string,line?:int,function:string,class:string,type:string,args:array<mixed>}>
268     */
269    public function get_backtrace( ?string $source_hash = null, ?int $steps = null ): array {
270
271        if ( ! empty( $source_hash ) ) {
272            $backtrace_cached = $this->get_cached_backtrace( $source_hash );
273
274            if ( is_array( $backtrace_cached ) ) {
275                return $backtrace_cached;
276            }
277        }
278
279        // This is critical to the library.
280        // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
281        $backtrace = debug_backtrace( false );
282
283        $ignore_starting_frame = function ( array $frame ): bool {
284            switch ( true ) {
285                case isset( $frame['file'] ) && __FILE__ === $frame['file']:
286                case 'call_user_func_array' === $frame['function']:
287                case isset( $frame['file'] ) && basename( $frame['file'] ) === 'class-php-error-handler.php':
288                case isset( $frame['file'] ) && basename( $frame['file'] ) === 'class-functions.php':
289                case isset( $frame['file'] ) && false !== stripos( $frame['file'], 'bh-wp-logger/includes' ):
290                case isset( $frame['file'] ) && false !== stripos( $frame['file'], 'psr/log/Psr/Log/' ):
291                case isset( $frame['file'] ) && false !== strpos( $frame['file'], 'php-http/logger-plugin' ):
292                    return true;
293                default:
294                    return false;
295            }
296        };
297
298        foreach ( $backtrace as $index => $frame ) {
299            if ( $ignore_starting_frame( $frame ) ) {
300                unset( $backtrace[ $index ] );
301            } else {
302                break;
303            }
304        }
305
306        if ( ! is_null( $source_hash ) ) {
307            $this->set_cached_backtrace( $source_hash, $backtrace );
308        }
309
310        return $backtrace;
311    }
312
313    /**
314     * Save the backtrace to a global cache.
315     *
316     * @see PHP_Error_Handler::is_related_error()
317     *
318     * If other plugins are using bh-wp-logger, they can use this cache to avoid rerunning the backtrace.
319     *
320     * @see debug_backtrace()
321     *
322     * @param string                                                                                          $source_hash A unique identifier for backtrace – `implode(func_get_args())` of the error handler is used.
323     * @param array<array{file?:string,line?:int,function:string,class:string,type:string,args:array<mixed>}> $backtrace The PHP backtrace, presumably filtered to remove irrelevant frames.
324     */
325    protected function set_cached_backtrace( string $source_hash, array $backtrace ): void {
326        if ( ! isset( $GLOBALS['bh_wp_logger_cache'] ) ) {
327            $GLOBALS['bh_wp_logger_cache'] = array();
328        }
329        $source_hash                                   = sanitize_key( $source_hash );
330        $GLOBALS['bh_wp_logger_cache'][ $source_hash ] = $backtrace;
331    }
332
333    /**
334     * Check if the backtrace for this error already been run.
335     *
336     * @param string $source_hash A unique identifier for backtrace.
337     *
338     * @return ?array<array{file?:string,line?:int,function:string,class:string,type:string,args:array<mixed>}>
339     */
340    protected function get_cached_backtrace( string $source_hash ): ?array {
341        $source_hash = sanitize_key( $source_hash );
342        return isset( $GLOBALS['bh_wp_logger_cache'], $GLOBALS['bh_wp_logger_cache'][ $source_hash ] )
343            ? $GLOBALS['bh_wp_logger_cache'][ $source_hash ]
344            : null;
345    }
346
347    /**
348     * Checks each file in the backtrace and if it contains WP_PLUGINS_DIR/plugin-slug then return true.
349     *
350     * @param ?string $source_hash A unique identifier for the source of the log entry.
351     */
352    public function is_backtrace_contains_plugin( ?string $source_hash = null ): bool {
353
354        $frames = $this->get_backtrace( $source_hash, null );
355
356        $is_file_from_plugin = false;
357
358        foreach ( $frames as $frame ) {
359
360            if ( isset( $frame['file'] ) && $this->is_file_from_plugin( $frame['file'] ) ) {
361                $is_file_from_plugin = true;
362                break;
363            }
364        }
365
366        return $is_file_from_plugin;
367    }
368
369    /**
370     * Given a filepath, tries to determine if this file is part of this plugin.
371     *
372     * TODO: Be consistent about meanings of plugin-slug and plugin basename.
373     * TODO: Remove code duplication with Plugins class.
374     *
375     * @see Plugins::get_plugin_data_from_slug()
376     *
377     * @param string $filepath Path to the file to be checked.
378     */
379    public function is_file_from_plugin( string $filepath ): bool {
380        return 0 === strpos( plugin_basename( realpath( $filepath ) ), $this->settings->get_plugin_slug() );
381    }
382
383    /**
384     * Get the name of the plugin-specific transient which indicates the last log message time.
385     *
386     * The value is used on plugins.php to show if new logs have been recorded.
387     * A transient is used to avoid re-calculating it from reading the log files from disk.
388     *
389     * @return string
390     */
391    public function get_last_log_time_transient_name(): string {
392        return $this->settings->get_plugin_slug() . '-last-log-time';
393    }
394
395    /**
396     * Used on plugins.php to highlight the logs link if there are new logs since they were last viewed.
397     *
398     * Read the log file backwards from the last character. Each time a newline character is found, check the buffer
399     * to see was there a data at the beginning of the line. Return the first date found.
400     *
401     * @see https://stackoverflow.com/a/15017711/336146
402     *
403     * @return ?DateTimeInterface
404     */
405    public function get_last_log_time(): ?DateTimeInterface {
406
407        $transient_name = $this->get_last_log_time_transient_name();
408
409        $transient_value = get_transient( $transient_name );
410
411        if ( ! empty( $transient_value ) ) {
412            return new DateTimeImmutable( $transient_value, new DateTimeZone( 'UTC' ) );
413        }
414
415        $log_files = $this->get_log_files();
416
417        $reverse_chronological_log_files = array_reverse( $log_files );
418
419        foreach ( $reverse_chronological_log_files as $last_log_file_path ) {
420
421            $file_pointer = fopen( $last_log_file_path, 'r' );
422
423            if ( false === $file_pointer ) {
424                $this->logger->warning( "Failed opening log file at {$last_log_file_path}." );
425                continue;
426            }
427
428            $offset_position = - 2;
429
430            $current_line = '';
431
432            while ( - 1 !== fseek( $file_pointer, $offset_position, SEEK_END ) ) {
433                $character = fgetc( $file_pointer );
434                if ( PHP_EOL === $character ) {
435
436                    if ( 1 === preg_match( '/^(?P<time>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.{1}\d{2}:\d{2})\s/im', $current_line, $output_array ) ) {
437                        set_transient( $transient_name, $output_array['time'], DAY_IN_SECONDS );
438                        // Log time will always be UTC.
439                        return new DateTimeImmutable( $output_array['time'], new DateTimeZone( 'UTC' ) );
440                    }
441
442                    $current_line = '';
443                } else {
444                    $current_line = $character . $current_line;
445                }
446                --$offset_position;
447            }
448        }
449
450        return null;
451    }
452
453    /**
454     * Used on plugins.php to highlight the logs link if there are new logs since they were last viewed.
455     *
456     * @return ?DateTimeInterface
457     */
458    public function get_last_logs_view_time(): ?DateTimeInterface {
459
460        $option_name                    = $this->settings->get_plugin_slug() . '-last-logs-view-time';
461        $last_log_view_time_atom_string = get_option( $option_name );
462        $last_log_view_time_datetime    = DateTimeImmutable::createFromFormat( DateTimeInterface::ATOM, $last_log_view_time_atom_string, new DateTimeZone( 'UTC' ) );
463
464        if ( false === $last_log_view_time_datetime ) {
465            delete_option( $option_name );
466            return null;
467        }
468
469        return $last_log_view_time_datetime;
470    }
471
472    /**
473     * Record the last time the logs were viewed in order to determine if admin notices should or should not be displayed.
474     * i.e. "mark read".
475     *
476     * @used-by Logs_Page
477     *
478     * @param ?DateTimeInterface $date_time A time to set, defaults to "now".
479     *
480     * @return void
481     */
482    public function set_last_logs_view_time( ?DateTimeInterface $date_time = null ): void {
483        $option_name = $this->settings->get_plugin_slug() . '-last-logs-view-time';
484
485        if ( is_null( $date_time ) ) {
486            try {
487                $date_time = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
488            } catch ( Exception $exception ) {
489                // This will never happen.
490                return;
491            }
492        }
493
494        $atom_time_string = $date_time->format( DateTimeInterface::ATOM );
495
496        update_option( $option_name, $atom_time_string );
497    }
498
499    /**
500     * Given the path to a log file, this returns an array of arrays – an array of log entries, each of which is an
501     * array containing the time, level, message and context.
502     *
503     * @used-by API::get_last_log_time()
504     * @used-by Logs_List_Table::get_data()
505     *
506     * @param string $filepath Path to the log file to read.
507     *
508     * @return array<array{time:string,datetime:DateTime|null,level:string,message:string,context:\stdClass|null}>
509     */
510    public function parse_log( string $filepath ): array {
511
512        $file_lines = file( $filepath );
513
514        if ( false === $file_lines ) {
515            // Failed to read file.
516            return array();
517        }
518
519        $entries = array();
520
521        if ( $this->logger instanceof WC_PSR_Logger ) {
522            $pattern = '/^(?P<time>[^\s]*)\s(?P<level>\w*)\s(?P<message>.*?)\sCONTEXT:\s(?P<context>.*)/';
523        } else {
524            $pattern = '/(?P<time>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.{1}\d{2}:\d{2})\s(?P<level>\w*)\s(?P<message>.*)/im';
525        }
526
527        // TODO: This will fail if the first line does not parse.
528        foreach ( $file_lines as $input_line ) {
529            $output_array = array();
530            if ( 1 === preg_match( $pattern, $input_line, $output_array ) ) {
531                $entries[] = array(
532                    'line_one_parsed' => $output_array,
533                    'lines'           => array( $output_array['context'] ?? '' ),
534                );
535            } else {
536                $entries[ count( $entries ) - 1 ]['lines'][] = $input_line;
537            }
538        }
539
540        $data = array_map( array( $this, 'log_lines_to_entry' ), $entries );
541
542        return $data;
543    }
544
545    /**
546     * Given the set of lines that constitutes a single log entry, this parses them into the array of time, level, message, context.
547     *
548     * @used-by API::parse_log()
549     *
550     * @param array{line_one_parsed:array{time:string,level:string,message:string}, lines:string[]} $input_lines A single log entries as a set of lines.
551     *
552     * @return array{time:string,datetime:DateTime|null,level:string,message:string,context:\stdClass|null}
553     */
554    protected function log_lines_to_entry( array $input_lines ): array {
555
556        $entry = array();
557
558        $time_string = $input_lines['line_one_parsed']['time'];
559        $str_time    = strtotime( $time_string );
560        // E.g. "2020-10-23T17:39:36+00:00".
561        $datetime = DateTime::createFromFormat( 'U', "{$str_time}" ) ?: null;
562
563        $level = $input_lines['line_one_parsed']['level'];
564
565        $message = $input_lines['line_one_parsed']['message'];
566
567        // Assume all lines that do not begin with a date should be joined together as context object.
568        $context = json_decode( implode( PHP_EOL, $input_lines['lines'] ) );
569        if ( is_null( $context ) ) {
570            foreach ( $input_lines['lines'] as $input_line ) {
571                // This is a bug but I'm not going to fix it until I see the problem exist.
572                // What happens if there is multiple lines that for some reason are valid JSON? Data will be lost in display.
573                $context = json_decode( $input_line );
574                if ( is_null( $context ) ) {
575                    $message .= $input_line;
576                }
577            }
578        }
579
580        if ( ! is_null( $context ) && isset( $context->source ) ) {
581            unset( $context->source );
582        }
583
584        $entry['time']     = $time_string;
585        $entry['datetime'] = $datetime;
586        $entry['level']    = $level;
587        $entry['message']  = $message;
588        $entry['context']  = $context;
589
590        return $entry;
591    }
592}