'generated-log',
'plural' => 'generated-logs',
'ajax' => true,
'screen' => WP_Helper::get_wp_screen(),
)
);
self::$table_name = 'php_error_logs';
}
/**
* Displays the search box.
*
* @since 1.1.0
*
* @param string $text The 'submit' button label.
* @param string $input_id ID attribute value for the search input field.
*/
public function search_box( $text, $input_id ) {
if ( empty( $_REQUEST[ self::SEARCH_INPUT ] ) && ! $this->has_items() ) {
return;
}
$input_id = $input_id . '-search-input';
?>
'search-submit' ) ); ?>
'', // to display the checkbox.
'timestamp' => esc_html(
sprintf(
/* translators: %s: UTC offset */
__( 'Time (%s)', '0-day-analytics' ),
WP_Helper::get_timezone_location()
)
),
'severity' => __( 'Severity', '0-day-analytics' ),
'message' => __( 'Message', '0-day-analytics' ),
'plugin_theme' => __( 'Source', '0-day-analytics' ),
);
self::$admin_columns = \array_merge( $admin_fields, $columns );
}
return self::$admin_columns;
}
/**
* Returns the table name.
*
* @since 1.1.0
*/
public static function get_table_name(): string {
return self::$table_name;
}
/**
* Prepares the list of items for displaying.
*
* Query, filter data, handle sorting, and pagination, and any other data-manipulation required prior to rendering
*
* @since 1.1.0
*/
public function prepare_items() {
$columns = $this->get_columns();
$hidden = array();
// $sortable = $this->get_sortable_columns();
$sortable = array();
$this->_column_headers = array( $columns, $hidden, $sortable );
$this->fetch_table_data();
$hidden = \get_user_option( 'manage' . WP_Helper::get_wp_screen()->id . 'columnshidden', false );
if ( ! $hidden ) {
$hidden = array();
}
$this->_column_headers = array( self::$columns, $hidden, $sortable );
}
/**
* Returns the currently hidden column headers for the current user
*
* @return array
*
* @since 1.1.0
*/
public static function get_hidden_columns() {
return array_filter(
(array) \get_user_option( 'manage' . Settings::get_main_menu_page_hook() . 'columnshidden', false )
);
}
/**
* Get a list of sortable columns. The format is:
* 'internal-name' => 'orderby'
* or
* 'internal-name' => array( 'orderby', true ).
*
* The second format will make the initial sorting order be descending
*
* @since 1.1.0
*
* @return array
*/
public function get_sortable_columns() {
$first6_columns = array_keys( self::get_column_names() );
$sortable_columns = array();
unset( $first6_columns[0], $first6_columns[9] ); // id column.
// data column.
/*
* Actual sorting still needs to be done by prepare_items.
* specify which columns should have the sort icon.
*
* The second bool param sets the colum sort order - true ASC, false - DESC or unsorted.
*/
foreach ( $first6_columns as $value ) {
$sortable_columns[ $value ] = array( $value, false );
}
return $sortable_columns;
}
/**
* Text displayed when no user data is available.
*
* @since 1.1.0
*
* @return void
*/
public function no_items() {
$debug_off = ( ! defined( 'WP_DEBUG' ) || ! \WP_DEBUG );
$debug_log_off = ( ! defined( 'WP_DEBUG_LOG' ) || ! \WP_DEBUG_LOG );
$settings_url = \esc_url( \admin_url( 'admin.php?page=' . Settings::SETTINGS_MENU_SLUG ) );
if ( $debug_off || $debug_log_off ) {
?>
WP_DEBUG'
);
} else {
printf(
/* translators: %s: WP_DEBUG_LOG constant name */
\esc_html__( 'The %s constant is not enabled. Errors are not being written to a log file.', '0-day-analytics' ),
'WP_DEBUG_LOG'
);
}
?>
',
''
);
?>
get_error_code() ) {
$severity = 'notice';
}
return array(
array(
'message' => Error_Log::get_last_error()->get_error_message(),
'severity' => $severity,
'timestamp' => time(),
),
);
}
$items = ( ! $items ) ? self::get_screen_option_per_page() : $items;
// Resolve time-range cutoff timestamp.
$time_cutoff = 0;
if ( ! empty( self::$query_args['time_range'] ) ) {
$range = self::$query_args['time_range'];
$now = time();
switch ( $range ) {
case 'last_hour':
$time_cutoff = $now - HOUR_IN_SECONDS;
break;
case 'today':
$time_cutoff = strtotime( 'today midnight' );
break;
case 'last_24h':
$time_cutoff = $now - DAY_IN_SECONDS;
break;
case 'last_7d':
$time_cutoff = $now - ( 7 * DAY_IN_SECONDS );
break;
}
}
// Determine if post-read filtering will be applied (search or plugin filter).
$has_search_filter = ! empty( $_REQUEST[ self::SEARCH_INPUT ] ) && is_string( $_REQUEST[ self::SEARCH_INPUT ] );
$has_plugin_filter = ! empty( self::$query_args ) && isset( self::$query_args['plugin_filter'] ) && ! empty( self::$query_args['plugin_filter'] ) && -1 !== (int) self::$query_args['plugin_filter'];
$has_post_filter = $has_search_filter || $has_plugin_filter;
// When post-read filters are active, read more items to compensate for filtering.
$read_items = $has_post_filter ? $items * 3 : $items;
$target_count = $items;
// Safety limit: maximum total read iterations to prevent runaway reads.
$max_read_passes = $has_post_filter ? 5 : 1;
$read_pass = 0;
while ( $read_pass < $max_read_passes ) {
++$read_pass;
$disabled = Settings::get_disabled_severities();
$remaining = $read_items;
while ( $remaining > 0 ) {
$result = Reverse_Line_Reader::read_file_from_end(
$file,
function( $line, $pos ) use ( &$collected_items, &$errors, &$position, $read_items, $write_temp, $time_cutoff ) {
$position = $pos;
// Prevent memory exhaustion from extremely large multi-line errors.
if ( count( $collected_items ) > 500 ) { // Reasonable limit for stack traces.
$collected_items = array_slice( $collected_items, -100 ); // Keep last 100 items.
}
// Flag that holds the status of the error - are there more lines to read or not.
$more_to_error = false;
// Check if this is the last line, and if not try to parse the line.
if ( ! empty( $line ) && null !== Log_Line_Parser::parse_entry_with_stack_trace( $line ) ) {
$parsed_data = Log_Line_Parser::parse_php_error_log_stack_line( $line );
if ( \is_array( $parsed_data ) && isset( $parsed_data['message'] ) ) {
if ( ! empty( $collected_items ) ) {
$parsed_data['sub_items'] = $collected_items;
if ( isset( $collected_items[0]['message'] ) ) {
$parsed_data['message'] = $collected_items[0]['message'] . "\n" . $parsed_data['message'];
unset( $collected_items[0] );
}
$collected_items = array();
}
// Time-range cutoff: stop reading if entry is older than cutoff.
if ( $time_cutoff > 0 && isset( $parsed_data['timestamp'] ) && $parsed_data['timestamp'] < $time_cutoff ) {
return array(
'close' => true,
'line_done' => true,
'no_flush' => true,
);
}
$errors[] = $parsed_data;
$more_to_error = false;
// Prevent memory exhaustion from too many errors.
if ( count( $errors ) >= $read_items * 2 ) { // Allow some buffer.
return array(
'close' => true,
'line_done' => true,
'no_flush' => false,
);
}
} elseif ( \is_array( $parsed_data ) ) {
if ( isset( $parsed_data['call'] ) && str_starts_with( trim( $parsed_data['call'] ), 'made by' ) ) {
$collected_items[] = array(
'message' => $parsed_data['call'],
);
} else {
$collected_items[] = $parsed_data;
}
$more_to_error = true;
}
} elseif ( ! empty( $line ) ) {
$more_to_error = true;
}
$is_excluded = false;
// Empty lines should not consume a pagination slot.
if ( empty( $line ) && ! $more_to_error ) {
$is_excluded = true;
}
if ( ! $more_to_error && ! $is_excluded ) {
$done = false;
if ( isset( $parsed_data['severity'] ) && \in_array( $parsed_data['severity'], Settings::get_disabled_severities(), true ) ) {
$is_excluded = true;
$done = true; // Error is removed - no need to check it again.
}
// If only one severity is selected - remove all others (otherwise plugin shows other non-defined / custom severities coming from unknown or 3rd party sources).
if ( ! $done && isset( $parsed_data['severity'] ) && count( Settings::get_enabled_severities() ) === 1 ) {
$enabled = Settings::get_enabled_severities();
if ( reset( $enabled ) !== $parsed_data['severity'] ) {
$is_excluded = true;
}
}
}
return array(
'line_done' => ! $more_to_error,
'close' => false,
'no_flush' => $is_excluded,
);
},
$remaining,
$position,
$write_temp
);
if ( ! empty( $disabled ) ) {
$last_error = end( $errors );
$done = false;
if ( isset( $last_error['severity'] ) && \in_array( $last_error['severity'], $disabled, true ) ) {
// Remove the last error if it is disabled.
array_pop( $errors );
$done = true; // Error is removed - no need to check it again.
}
// If only one severity is selected - remove all others (otherwise plugin shows other non-defined / custom severities coming from unknown or 3rd party sources).
if ( ! $done && isset( $last_error['severity'] ) && count( Settings::get_enabled_severities() ) === 1 ) {
$enabled = Settings::get_enabled_severities();
if ( reset( $enabled ) !== $last_error['severity'] ) {
array_pop( $errors );
}
}
}
$last_error = end( $errors );
// Add plugin data but only if that is not request for the first error.
if ( ! empty( $last_error ) && ! $first_only ) {
$plugin = Plugin_Theme_Helper::get_plugin_from_file_path( $last_error['message'] );
if ( $plugin ) {
$errors[ \array_key_last( $errors ) ]['plugin'] = $plugin;
}
}
if ( $first_only && ! empty( $errors ) ) {
// If we only want the first item, return it.
Log_Line_Parser::store_last_parsed_timestamp();
return array( reset( $errors ) );
}
if ( false === $result ) {
break 2;
}
}
// Apply post-read filters and check if we have enough results.
// If not, continue reading in the next pass.
if ( $has_post_filter && $read_pass < $max_read_passes ) {
$filtered = $errors;
if ( $has_plugin_filter ) {
$pf = self::$query_args['plugin_filter'];
$filtered = array_filter(
$filtered,
function ( $error ) use ( $pf ) {
return isset( $error['plugin'] ) && ( false !== strpos( $error['plugin'], $pf ) );
}
);
}
if ( $has_search_filter ) {
$s = \sanitize_text_field( \wp_unslash( $_REQUEST[ self::SEARCH_INPUT ] ) );
$filtered = array_filter(
$filtered,
function ( $event ) use ( $s ) {
$ar_as_string = ( isset( $event['sub_items'] ) && is_array( $event['sub_items'] ) ) ? \wp_json_encode( $event['sub_items'] ) : '';
return ( false !== strpos( $event['message'], $s ) ) || ( false !== strpos( $ar_as_string, $s ) );
}
);
}
if ( count( $filtered ) >= $target_count ) {
// We have enough matching results, stop reading.
break;
}
// Not enough matching results yet — read another batch.
$read_items = $items * 2;
} else {
break;
}
}
Log_Line_Parser::store_last_parsed_timestamp();
// Apply time-range cutoff to collected errors (for entries at the boundary).
if ( $time_cutoff > 0 ) {
$errors = array_filter(
$errors,
function ( $error ) use ( $time_cutoff ) {
return ! isset( $error['timestamp'] ) || $error['timestamp'] >= $time_cutoff;
}
);
}
if ( $has_plugin_filter ) {
$s = self::$query_args['plugin_filter'];
$errors = array_filter(
$errors,
function ( $error ) use ( $s ) {
if ( isset( $error['plugin'] ) ) {
return ( false !== strpos( $error['plugin'], $s ) );
} else {
return false;
}
}
);
}
if ( $has_search_filter ) {
$s = \sanitize_text_field( \wp_unslash( $_REQUEST[ self::SEARCH_INPUT ] ) );
$errors = array_filter(
$errors,
function ( $event ) use ( $s ) {
$ar_as_string = ( isset( $event['sub_items'] ) && is_array( $event['sub_items'] ) ) ? \wp_json_encode( $event['sub_items'] ) : '';
return ( false !== strpos( $event['message'], $s ) ) || ( false !== strpos( $ar_as_string, $s ) );
}
);
}
// Trim to requested count after filtering.
if ( count( $errors ) > $target_count ) {
$errors = array_slice( $errors, 0, $target_count );
}
self::$items_collected = $errors;
$errors = null;
unset( $errors );
return self::$items_collected;
}
/**
* Render a column when no column specific method exists.
*
* Use that method for common rendering and separate columns logic in different methods. See below.
*
* @param array $item - Array with the current row values.
* @param string $column_name - The name of the currently processed column.
*
* @return mixed
*
* @since 1.1.0
*/
public function column_default( $item, $column_name ) {
return self::format_column_value( $item, $column_name );
}
/**
* Render a column when no column specific method exists.
*
* Use that method for common rendering and separate columns logic in different methods. See below.
*
* @param array $item - Array with the current row values.
* @param string $column_name - The name of the currently processed column.
*
* @return mixed
*
* @since 1.1.0
*/
public static function format_column_value( $item, $column_name ) {
switch ( $column_name ) {
case 'severity':
if ( isset( $item['severity'] ) && ! empty( $item['severity'] ) ) {
if ( isset( Settings::get_option( 'severities' )[ $item['severity'] ] ) ) {
return '' . \esc_html( $item['severity'] ) . '';
} else {
return '' . \esc_html( $item['severity'] ) . '';
}
} else {
return '' . \esc_html( 'not set' ) . '';
}
break;
case 'timestamp':
if ( 1 === $item['timestamp'] ) {
return sprintf(
' %s',
\esc_html__( 'Immediately', '0-day-analytics' ),
);
}
$time_format = 'g:i a';
$event_datetime_utc = \gmdate( 'Y-m-d H:i:s', $item['timestamp'] );
$timezone_local = \wp_timezone();
$event_local = \get_date_from_gmt( $event_datetime_utc, 'Y-m-d' );
$today_local = ( new \DateTimeImmutable( 'now', $timezone_local ) )->format( 'Y-m-d' );
$tomorrow_local = ( new \DateTimeImmutable( 'tomorrow', $timezone_local ) )->format( 'Y-m-d' );
$yesterday_local = ( new \DateTimeImmutable( 'yesterday', $timezone_local ) )->format( 'Y-m-d' );
// If the offset of the date of the event is different from the offset of the site, add a marker.
if ( \get_date_from_gmt( $event_datetime_utc, 'P' ) !== get_date_from_gmt( 'now', 'P' ) ) {
$time_format .= ' (P)';
}
$event_time_local = \get_date_from_gmt( $event_datetime_utc, $time_format );
if ( $event_local === $today_local ) {
$date = sprintf(
/* translators: %s: Time */
__( 'Today at %s', '0-day-analytics' ),
$event_time_local,
);
} elseif ( $event_local === $tomorrow_local ) {
$date = sprintf(
/* translators: %s: Time */
__( 'Tomorrow at %s', '0-day-analytics' ),
$event_time_local,
);
} elseif ( $event_local === $yesterday_local ) {
$date = sprintf(
/* translators: %s: Time */
__( 'Yesterday at %s', '0-day-analytics' ),
$event_time_local,
);
} else {
$date = sprintf(
/* translators: 1: Date, 2: Time */
__( '%1$s at %2$s', '0-day-analytics' ),
\get_date_from_gmt( $event_datetime_utc, 'F jS' ),
$event_time_local,
);
}
$time = sprintf(
'',
\esc_attr( gmdate( 'c', $item['timestamp'] ) ),
\esc_html( $date )
);
$until = $item['timestamp'] - time();
if ( $until < 0 ) {
$ago = sprintf(
/* translators: %s: Time period, for example "8 minutes" */
__( '%s ago', '0-day-analytics' ),
WP_Helper::interval( abs( $until ) )
);
return sprintf(
' %s %s',
esc_html( $ago ),
$time,
);
} elseif ( 0 === $until ) {
$in = __( 'Now', '0-day-analytics' );
} else {
$in = sprintf(
/* translators: %s: Time period, for example "8 minutes" */
__( 'In %s', '0-day-analytics' ),
WP_Helper::interval( $until ),
);
}
return sprintf(
' %s %s',
\esc_html( $in ),
$time,
);
case 'message':
$message = '