'debug-log', 'label' => __( 'WordPress debug log', 'desktop-mode' ), 'path' => $debug_path, ); } $ini_log = (string) ini_get( 'error_log' ); if ( '' !== $ini_log && ! in_array( $ini_log, array( 'syslog', '/dev/stderr', '/dev/stdout' ), true ) ) { $same = '' !== $debug_path && ( $ini_log === $debug_path || ( file_exists( $ini_log ) && file_exists( $debug_path ) && realpath( $ini_log ) === realpath( $debug_path ) ) ); if ( ! $same ) { $sources[] = array( 'id' => 'php-error-log', 'label' => __( 'PHP error log', 'desktop-mode' ), 'path' => $ini_log, ); } } /** * Filter the log sources offered by the Code Blue window. * * Each entry declares `id` (slug), `label`, and `path` (absolute * file path). File metadata (`exists`, `readable`, `writable`, * `size`, `mtime`) is derived after filtering — callers only * supply the three descriptor keys. * * @param array[] $sources Default: WP debug log + PHP error log. */ $sources = apply_filters( 'openstation_code_blue_log_sources', $sources ); $out = array(); $seen = array(); foreach ( (array) $sources as $source ) { $id = isset( $source['id'] ) ? sanitize_key( (string) $source['id'] ) : ''; $path = isset( $source['path'] ) ? (string) $source['path'] : ''; if ( '' === $id || '' === $path || isset( $seen[ $id ] ) ) { continue; } $seen[ $id ] = true; $exists = is_file( $path ); $out[] = array( 'id' => $id, 'label' => isset( $source['label'] ) ? (string) $source['label'] : $id, 'path' => $path, 'exists' => $exists, 'readable' => $exists && is_readable( $path ), 'writable' => $exists && wp_is_writable( $path ), 'size' => $exists ? (int) filesize( $path ) : 0, 'mtime' => $exists ? (int) filemtime( $path ) : 0, ); } return $out; } /** * Look up one normalized source descriptor by id. * * @param string $id Source id. * @return array|null */ function openstation_code_blue_get_source( $id ) { foreach ( openstation_code_blue_log_sources() as $source ) { if ( $source['id'] === $id ) { return $source; } } return null; } /** * Read the trailing window of a file. * * When the file is larger than `$max_bytes`, seeks to the tail and * drops the first (almost certainly partial) line so parsing starts * on a clean record boundary. * * @param string $path Absolute file path. * @param int $max_bytes Trailing window size. * @return array `raw` (string), `truncated` (bool), `scanned_bytes` (int). */ function openstation_code_blue_tail( $path, $max_bytes ) { $result = array( 'raw' => '', 'truncated' => false, 'scanned_bytes' => 0, ); if ( ! is_file( $path ) || ! is_readable( $path ) ) { return $result; } $size = (int) filesize( $path ); if ( 0 === $size ) { return $result; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming the tail of a multi-megabyte log; WP_Filesystem has no seek-and-read. $handle = fopen( $path, 'rb' ); if ( ! $handle ) { return $result; } $offset = max( 0, $size - $max_bytes ); if ( $offset > 0 ) { fseek( $handle, $offset ); $result['truncated'] = true; } $raw = stream_get_contents( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Pairs with the fopen above. fclose( $handle ); if ( false === $raw ) { return $result; } if ( $offset > 0 ) { $newline = strpos( $raw, "\n" ); $raw = false === $newline ? '' : substr( $raw, $newline + 1 ); } $result['raw'] = $raw; $result['scanned_bytes'] = strlen( $raw ); return $result; } /** * The PHP error-label → severity map, the single source of truth * for both the parse regex (built from these keys) and the label * lookup — so a label added here is automatically matched. * * @return array Lowercased label => severity slug. */ function openstation_code_blue_level_map() { return array( 'fatal error' => 'fatal', 'parse error' => 'fatal', 'core error' => 'fatal', 'compile error' => 'fatal', 'recoverable fatal error' => 'fatal', 'user error' => 'error', 'warning' => 'warning', 'core warning' => 'warning', 'compile warning' => 'warning', 'user warning' => 'warning', 'deprecated' => 'deprecated', 'user deprecated' => 'deprecated', 'notice' => 'notice', 'user notice' => 'notice', ); } /** * Map a PHP error label (the text between `PHP ` and `:`) to one of * the six Code Blue severities. * * @param string $label Error label, e.g. `Fatal error`. * @return string `fatal` | `error` | `warning` | `deprecated` | `notice` | `info`. */ function openstation_code_blue_level_for_label( $label ) { $map = openstation_code_blue_level_map(); $label = strtolower( trim( $label ) ); return isset( $map[ $label ] ) ? $map[ $label ] : 'info'; } /** * Build the grouping signature for an entry. * * Two occurrences of "the same problem" must land on the same * signature even when the details differ, so numbers (line numbers, * ids, byte counts) and hex addresses are collapsed before hashing * the message together with its level and file. * * @param string $level Severity slug. * @param string $message Message with the location suffix removed. * @param string $file Source file path, may be empty. * @return string */ function openstation_code_blue_signature( $level, $message, $file = '' ) { $norm = preg_replace( '/0x[0-9a-f]+/i', 'N', $message ); $norm = preg_replace( '/\d+/', 'N', (string) $norm ); $norm = preg_replace( '/\s+/', ' ', trim( (string) $norm ) ); $norm = substr( (string) $norm, 0, 240 ); return $level . '|' . $norm . '|' . $file; } /** * Parse raw log text into structured entries. * * Understands the formats a WordPress install actually produces: * * - `[d-M-Y H:i:s TZ] PHP