Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 319 additions & 0 deletions inc/Abilities/LogAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,57 @@ private function registerAbilities(): void {
'meta' => array( 'show_in_rest' => true ),
)
);

wp_register_ability(
'datamachine/read-debug-log',
array(
'label' => 'Read WordPress Debug Log',
'description' => 'Read PHP debug.log entries from wp-content/debug.log',
'category' => 'datamachine',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'lines' => array(
'type' => 'integer',
'description' => 'Number of lines to read from end of file (default: 100, max: 1000)',
),
'level' => array(
'type' => 'string',
'enum' => array( 'error', 'warning', 'notice', 'deprecated', 'fatal', 'parse', 'all' ),
'description' => 'Filter by PHP error level (default: all)',
),
'since' => array(
'type' => 'string',
'description' => 'ISO datetime — entries after this time',
),
'search' => array(
'type' => 'string',
'description' => 'Free-text search in log messages',
),
'context' => array(
'type' => 'integer',
'description' => 'Lines of context to include around each match (default: 0)',
),
),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'file' => array( 'type' => 'string' ),
'entries' => array( 'type' => 'array' ),
'total' => array( 'type' => 'integer' ),
'filtered' => array( 'type' => 'integer' ),
'file_size' => array( 'type' => 'integer' ),
'last_modified' => array( 'type' => 'string' ),
'error' => array( 'type' => 'string' ),
),
),
'execute_callback' => array( self::class, 'readDebugLog' ),
'permission_callback' => fn() => PermissionHelper::can_manage(),
'meta' => array( 'show_in_rest' => true ),
)
);
};

if ( doing_action( 'wp_abilities_api_init' ) ) {
Expand Down Expand Up @@ -334,4 +385,272 @@ public static function getMetadata( array $input ): array {
'level_counts' => $level_counts,
);
}

/**
* Read WordPress debug.log file.
*
* Parses PHP error log entries and returns structured data.
* Supports filtering by level, time, and text search.
*
* @param array $input { lines, level, since, search, context }.
* @return array Structured log entries.
*/
public static function readDebugLog( array $input ): array {
$log_file = WP_CONTENT_DIR . '/debug.log';

// Check if debug.log exists.
if ( ! file_exists( $log_file ) ) {
return array(
'success' => false,
'error' => 'debug.log not found at ' . $log_file,
'file' => $log_file,
);
}

// Check if readable.
if ( ! is_readable( $log_file ) ) {
return array(
'success' => false,
'error' => 'debug.log is not readable',
'file' => $log_file,
);
}

// Get file metadata.
$file_size = filesize( $log_file );
$last_modified = gmdate( 'c', filemtime( $log_file ) );

// Parse parameters.
$max_lines = min( (int) ( $input['lines'] ?? 100 ), 1000 );
$level = $input['level'] ?? 'all';
$since = $input['since'] ?? null;
$search = $input['search'] ?? null;
$context = (int) ( $input['context'] ?? 0 );

// Read the file (tail approach for large files).
$entries = self::tailDebugLog( $log_file, $max_lines * 10 ); // Read more to allow filtering.

if ( empty( $entries ) ) {
return array(
'success' => true,
'file' => $log_file,
'entries' => array(),
'total' => 0,
'filtered' => 0,
'file_size' => $file_size,
'last_modified' => $last_modified,
);
}

// Parse entries into structured data.
$parsed = array();
foreach ( $entries as $line ) {
$entry = self::parseDebugLogLine( $line );
if ( $entry ) {
$parsed[] = $entry;
}
}

// Filter by level.
if ( 'all' !== $level ) {
$parsed = array_filter( $parsed, function ( $entry ) use ( $level ) {
return strtolower( $entry['level'] ) === strtolower( $level );
});
}

// Filter by timestamp.
if ( $since ) {
$since_timestamp = strtotime( $since );
if ( $since_timestamp ) {
$parsed = array_filter( $parsed, function ( $entry ) use ( $since_timestamp ) {
return $entry['timestamp'] >= $since_timestamp;
});
}
}

// Filter by search.
if ( $search ) {
$search_lower = strtolower( $search );
$parsed = array_filter( $parsed, function ( $entry ) use ( $search_lower ) {
return str_contains( strtolower( $entry['message'] ), $search_lower )
|| str_contains( strtolower( $entry['file'] ?? '' ), $search_lower );
});
}

// Re-index array.
$parsed = array_values( $parsed );

// Limit to requested lines.
$total_count = count( $parsed );
$parsed = array_slice( $parsed, 0, $max_lines );

return array(
'success' => true,
'file' => $log_file,
'entries' => $parsed,
'total' => $total_count,
'filtered' => count( $parsed ),
'file_size' => $file_size,
'last_modified' => $last_modified,
);
}

/**
* Tail the debug.log file efficiently.
*
* @param string $file File path.
* @param int $lines Number of lines to read.
* @return array Lines from the file.
*/
private static function tailDebugLog( string $file, int $lines ): array {
$handle = fopen( $file, 'r' );
if ( ! $handle ) {
return array();
}

// Seek to end.
fseek( $handle, 0, SEEK_END );
$pos = ftell( $handle );

// Read backwards to find line breaks.
$found_lines = array();
$line_buffer = '';

while ( $pos > 0 && count( $found_lines ) < $lines ) {
$pos--;
fseek( $handle, $pos, SEEK_SET );
$char = fgetc( $handle );

if ( "\n" === $char ) {
if ( '' !== trim( $line_buffer ) ) {
array_unshift( $found_lines, strrev( $line_buffer ) );
}
$line_buffer = '';
} else {
$line_buffer .= $char;
}
}

// Don't forget the last line if we hit the beginning.
if ( '' !== trim( $line_buffer ) && count( $found_lines ) < $lines ) {
array_unshift( $found_lines, strrev( $line_buffer ) );
}

fclose( $handle );

return $found_lines;
}

/**
* Parse a debug.log line into structured data.
*
* Handles common WordPress/PHP log formats:
* - [datetime] PHP level: message in file on line N
* - [datetime] PHP Fatal error: message in file on line N
* - [datetime] PHP Warning: message in file on line N
* - [datetime] PHP Notice: message in file on line N
* - [datetime] PHP Deprecated: message in file on line N
*
* @param string $line Raw log line.
* @return array|null Structured entry or null if unparseable.
*/
private static function parseDebugLogLine( string $line ): ?array {
$line = trim( $line );
if ( empty( $line ) ) {
return null;
}

// Common format: [datetime] PHP Level: message in /path/to/file.php on line N
// WordPress format: [datetime] PHP Level: message
// Handles multi-word levels like "Fatal error", "Parse error", etc.
if ( preg_match( '/^\[([^\]]+)\]\s*(?:PHP\s+)?([A-Za-z]+(?:\s+[A-Za-z]+)?)?:\s*(.+)$/i', $line, $matches ) ) {
$timestamp_str = $matches[1];
$level = strtoupper( $matches[2] ?? 'UNKNOWN' );
$message_part = $matches[3];

// Parse timestamp (WordPress format: 01-Jan-2026 12:34:56+00:00).
$timestamp = strtotime( $timestamp_str ) ?: 0;

// Extract file and line from message if present.
$file = null;
$line_number = null;
if ( preg_match( '/in\s+(.+\.php)(?:\s+on\s+line\s+(\d+))?$/i', $message_part, $file_matches ) ) {
$file = $file_matches[1];
$line_number = isset( $file_matches[2] ) ? (int) $file_matches[2] : null;
// Remove file/line from message for cleaner output.
$message_part = trim( preg_replace( '/\s*in\s+.+\.php(?:\s+on\s+line\s+\d+)?$/i', '', $message_part ) );
}

// Normalize level.
$level = self::normalizeLogLevel( $level );

return array(
'raw' => $line,
'timestamp' => $timestamp,
'datetime' => $timestamp ? gmdate( 'c', $timestamp ) : $timestamp_str,
'level' => $level,
'message' => $message_part,
'file' => $file,
'line' => $line_number,
);
}

// Stack trace line.
if ( preg_match( '/^#\d+\s+/', $line ) ) {
return array(
'raw' => $line,
'timestamp' => null,
'datetime' => null,
'level' => 'STACK_TRACE',
'message' => $line,
'file' => null,
'line' => null,
);
}

// Unrecognized format — return as raw.
return array(
'raw' => $line,
'timestamp' => null,
'datetime' => null,
'level' => 'UNKNOWN',
'message' => $line,
'file' => null,
'line' => null,
);
}

/**
* Normalize PHP error level to standard terms.
*
* @param string $level Raw level string.
* @return string Normalized level.
*/
private static function normalizeLogLevel( string $level ): string {
$level = strtoupper( trim( $level ) );

$map = array(
'FATAL ERROR' => 'FATAL',
'FATAL' => 'FATAL',
'ERROR' => 'ERROR',
'WARNING' => 'WARNING',
'PARSE ERROR' => 'PARSE',
'PARSE' => 'PARSE',
'NOTICE' => 'NOTICE',
'STRICT' => 'NOTICE',
'DEPRECATED' => 'DEPRECATED',
'CORE ERROR' => 'FATAL',
'CORE WARNING' => 'WARNING',
'COMPILE ERROR' => 'FATAL',
'COMPILE WARNING' => 'WARNING',
'USER ERROR' => 'ERROR',
'USER WARNING' => 'WARNING',
'USER NOTICE' => 'NOTICE',
'USER DEPRECATED' => 'DEPRECATED',
'RECOVERABLE ERROR' => 'ERROR',
'CATCHABLE FATAL ERROR' => 'ERROR',
);

return $map[ $level ] ?? 'UNKNOWN';
}
}
Loading
Loading