| Current File : /home/digitaw/www/wp-content/plugins/simple-history/inc/channels/class-file-channel.php |
<?php
namespace Simple_History\Channels;
use Simple_History\Channels\Formatters\Formatter_Interface;
use Simple_History\Channels\Formatters\Human_Readable_Formatter;
use Simple_History\Helpers;
/**
* File Channel for Simple History.
*
* This channel automatically writes all log events to files,
* providing a backup mechanism and demonstrating the channel system.
* This is included in the free version of Simple History.
*
* @since 4.4.0
*/
class File_Channel extends Channel {
/**
* Delay before running cleanup after a log write (in seconds).
* Daily is sufficient since files only rotate daily/weekly/monthly.
*/
private const CLEANUP_DELAY_SECONDS = DAY_IN_SECONDS;
/**
* The unique slug for this channel.
*
* @var ?string
*/
protected ?string $slug = 'file';
/**
* Whether this channel supports async processing.
* File writing is fast, no need for async.
*
* @var bool
*/
protected bool $supports_async = false;
/**
* Called when the channel is loaded and ready.
*
* Registers hooks for async cleanup processing.
*/
public function loaded() {
add_action( 'simple_history_cleanup_log_files', [ $this, 'handle_async_cleanup' ] );
}
/**
* Get the selected formatter slug with fallback.
*
* Returns the saved formatter slug if it exists in available formatters,
* otherwise falls back to 'human_readable'. This handles the case where
* a user had the premium add-on with additional formatters, selected one,
* and then disabled the premium add-on.
*
* @return string The formatter slug.
*/
private function get_selected_formatter_slug(): string {
$formatter_slug = $this->get_setting( 'formatter', 'human_readable' );
$formatters = $this->get_available_formatters();
// Check if saved formatter exists in available formatters.
if ( isset( $formatters[ $formatter_slug ] ) ) {
return $formatter_slug;
}
// Fall back to human readable.
return 'human_readable';
}
/**
* Get the formatter instance for this channel.
*
* @return Formatter_Interface The formatter instance.
*/
private function get_formatter(): Formatter_Interface {
$formatter_slug = $this->get_selected_formatter_slug();
$formatters = $this->get_available_formatters();
// Return the formatter instance.
if ( isset( $formatters[ $formatter_slug ] ) && $formatters[ $formatter_slug ] instanceof Formatter_Interface ) {
return $formatters[ $formatter_slug ];
}
// Fall back to human readable (should not happen but safety first).
return new Human_Readable_Formatter();
}
/**
* Get available formatters.
*
* Returns an array of formatter instances keyed by slug.
* Each formatter provides its own name and description via get_name() and get_description().
*
* @return array<string, Formatter_Interface>
*/
private function get_available_formatters(): array {
$formatters = [
'human_readable' => new Human_Readable_Formatter(),
];
/**
* Filter available formatters for the file channel.
*
* Allows adding custom formatters. Each formatter must implement Formatter_Interface
* and provide get_name() and get_description() methods.
*
* Example:
*
* add_filter( 'simple_history/file_channel/formatters', function( $formatters ) {
* $formatters['my_format'] = new My_Custom_Formatter();
* return $formatters;
* } );
*
* @since 5.7.0
*
* @param array<string, Formatter_Interface> $formatters Array of formatter instances keyed by slug.
*/
return apply_filters( 'simple_history/file_channel/formatters', $formatters );
}
/**
* Get the display name for this channel.
*
* @return string The channel display name.
*/
public function get_name() {
return __( 'Local Files', 'simple-history' );
}
/**
* Get the description for this channel.
*
* @return string The channel description.
*/
public function get_description() {
return __( 'Write events to log files on this server for backup or import into analysis tools.', 'simple-history' );
}
/**
* Output HTML after the description in the intro section.
*/
public function settings_output_intro() {
?>
<p>
<?php esc_html_e( 'Log files are stored independently from the database, unaffected by "Clear log" or database retention settings.', 'simple-history' ); ?>
</p>
<?php
}
/**
* Send an event to this channel.
*
* @param array $event_data The event data to send.
* @param string $formatted_message The formatted message.
* @return bool True on success, false on failure.
*/
public function send_event( $event_data, $formatted_message ) {
// Don't write anything if channel is disabled.
if ( ! $this->is_enabled() ) {
return true;
}
// Get the log file path.
$log_file = $this->get_log_file_path();
if ( ! $log_file ) {
return false;
}
// Ensure directory exists.
$log_dir = dirname( $log_file );
if ( ! $this->ensure_directory_exists( $log_dir ) ) {
return false;
}
// Format the log entry using configured formatter.
$formatter = $this->get_formatter();
$log_entry = $formatter->format( $event_data, $formatted_message );
// Write to file.
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents -- Direct file operations required for log file channel.
$result = file_put_contents( $log_file, $log_entry, FILE_APPEND | LOCK_EX );
if ( false === $result ) {
return false;
}
// Schedule cleanup if needed.
$this->schedule_cleanup_if_needed();
return true;
}
/**
* Add settings fields for this channel using WordPress Settings API.
*
* @param string $settings_page_slug The settings page slug.
* @param string $settings_section_id The settings section ID.
*/
public function add_settings_fields( $settings_page_slug, $settings_section_id ) {
// Add parent's enable checkbox first.
parent::add_settings_fields( $settings_page_slug, $settings_section_id );
$option_name = $this->get_settings_option_name();
// Output format field.
add_settings_field(
$option_name . '_formatter',
Helpers::get_settings_field_title_output( __( 'Output format', 'simple-history' ) ),
[ $this, 'settings_field_formatter' ],
$settings_page_slug,
$settings_section_id
);
// File management field (rotation + retention combined).
add_settings_field(
$option_name . '_file_management',
Helpers::get_settings_field_title_output( __( 'File management', 'simple-history' ) ),
[ $this, 'settings_field_file_management' ],
$settings_page_slug,
$settings_section_id
);
// File path info field.
add_settings_field(
$option_name . '_file_path',
Helpers::get_settings_field_title_output( __( 'Log folder', 'simple-history' ) ),
[ $this, 'settings_field_file_path' ],
$settings_page_slug,
$settings_section_id
);
}
/**
* Render the "Enabled" settings field with a descriptive label.
*
* Overrides parent to provide a more descriptive checkbox label.
*/
public function settings_field_enabled() {
$enabled = $this->is_enabled();
$option_name = $this->get_settings_option_name();
?>
<label>
<input
type="checkbox"
name="<?php echo esc_attr( $option_name ); ?>[enabled]"
value="1"
<?php checked( $enabled ); ?>
/>
<?php esc_html_e( 'Enable file logging', 'simple-history' ); ?>
</label>
<?php
}
/**
* Render the combined file management settings field.
* Combines rotation frequency and file retention into a natural sentence.
*/
public function settings_field_file_management() {
$option_name = $this->get_settings_option_name();
$rotation_value = $this->get_setting( 'rotation_frequency', 'daily' );
$keep_files_value = $this->get_setting( 'keep_files', 30 );
$rotation_options = [
'daily' => __( 'daily', 'simple-history' ),
'weekly' => __( 'weekly', 'simple-history' ),
'monthly' => __( 'monthly', 'simple-history' ),
];
?>
<label class="sh-FileChannel-fileManagement">
<?php esc_html_e( 'Create a new file', 'simple-history' ); ?>
<select name="<?php echo esc_attr( $option_name ); ?>[rotation_frequency]">
<?php foreach ( $rotation_options as $value => $label ) { ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $rotation_value, $value ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php } ?>
</select>
<?php esc_html_e( 'and keep the last', 'simple-history' ); ?>
<input
type="number"
name="<?php echo esc_attr( $option_name ); ?>[keep_files]"
value="<?php echo esc_attr( $keep_files_value ); ?>"
min="1"
max="365"
class="small-text"
/>
<?php esc_html_e( 'files', 'simple-history' ); ?>
</label>
<?php
}
/**
* Render the output format settings field.
*/
public function settings_field_formatter() {
$option_name = $this->get_settings_option_name();
$selected_formatted_slug = $this->get_selected_formatter_slug();
$formatters = $this->get_available_formatters();
$is_premium_active = Helpers::is_premium_add_on_active();
?>
<fieldset class="sh-RadioOptions">
<?php
foreach ( $formatters as $formatter_slug => $formatter ) {
?>
<label class="sh-RadioOption">
<input
type="radio"
name="<?php echo esc_attr( $option_name ); ?>[formatter]"
value="<?php echo esc_attr( $formatter_slug ); ?>"
<?php checked( $selected_formatted_slug, $formatter_slug ); ?>
/>
<?php echo esc_html( $formatter->get_name() ); ?>
<span class="sh-RadioOptionDescription description">
<?php echo esc_html( $formatter->get_description() ); ?>
</span>
</label>
<?php
}
// Show disabled premium formatters when premium is not active.
if ( ! $is_premium_active ) {
$this->render_premium_formatter_teasers();
}
?>
</fieldset>
<?php
// Show premium promo box if premium add-on is not active.
if ( ! $is_premium_active ) {
echo wp_kses_post(
Helpers::get_premium_feature_teaser(
__( 'Unlock All Log Formats', 'simple-history' ),
[
__( 'JSON Lines, Logfmt, and Syslog formats', 'simple-history' ),
__( 'Compatible with Graylog, Splunk, Grafana Loki, and more', 'simple-history' ),
__( 'Machine-readable for easy parsing and analysis', 'simple-history' ),
],
'file_channel_formatters',
__( 'Unlock All Formats', 'simple-history' )
)
);
}
}
/**
* Render disabled radio buttons for premium formatters.
*
* Shows what premium formatters look like without being selectable,
* creating FOMO and demonstrating value of the premium add-on.
*/
private function render_premium_formatter_teasers() {
// Define premium formatters with their names and descriptions.
// These match the actual premium formatters for consistency.
$premium_formatters = [
'json_lines' => [
'name' => __( 'JSON Lines (GELF)', 'simple-history' ),
'description' => __( 'One JSON object per line. Best for Graylog, ELK, Splunk, and log aggregation tools.', 'simple-history' ),
],
'logfmt' => [
'name' => __( 'Logfmt', 'simple-history' ),
'description' => __( 'Key=value pairs. Best for Grafana Loki, Prometheus, and cloud-native log systems.', 'simple-history' ),
],
'rfc5424' => [
'name' => __( 'RFC 5424 Syslog', 'simple-history' ),
'description' => __( 'Standard syslog format with structured data. Best for syslog servers and SIEM tools.', 'simple-history' ),
],
];
foreach ( $premium_formatters as $formatter ) {
?>
<label class="sh-RadioOption sh-RadioOption--disabled">
<input
type="radio"
disabled
/>
<?php echo esc_html( $formatter['name'] ); ?>
<span class="sh-RadioOptionDescription description">
<?php echo esc_html( $formatter['description'] ); ?>
</span>
</label>
<?php
}
}
/**
* Test folder writability and attempt to create if needed.
*
* Returns an array with status information about the folder:
* - is_writable: bool - Whether the folder exists and is writable
* - creation_failed: bool - Whether creation was attempted but failed
*
* @param string $directory The directory path to test.
* @return array{is_writable: bool, creation_failed: bool}
*/
private function test_folder_writability( $directory ) {
$existed_before = is_dir( $directory );
$is_writable = $this->ensure_directory_exists( $directory );
return [
'is_writable' => $is_writable,
'creation_failed' => ! $existed_before && ! $is_writable,
];
}
/**
* Render the file path info field.
*/
public function settings_field_file_path() {
$log_directory = $this->get_log_directory_path();
$test_url = $this->get_log_directory_url();
// Test folder writability and attempt creation if needed.
$folder_status = $this->test_folder_writability( $log_directory );
$creation_failed = $folder_status['creation_failed'];
$is_writable = $folder_status['is_writable'];
// Get stats for display.
$stats = $is_writable ? $this->get_log_files_stats() : null;
?>
<div class="sh-FileChannel-folderInfo">
<?php // Folder path. ?>
<code class="sh-FileChannel-folderPath"><?php echo esc_html( $log_directory ); ?></code>
<?php // Status line with icon. ?>
<p class="sh-FileChannel-folderStatus">
<?php if ( $creation_failed ) { ?>
<span class="sh-FileChannel-folderStatus--error">
<span class="dashicons dashicons-warning"></span>
<?php esc_html_e( 'Folder could not be created. Check that the parent directory is writable.', 'simple-history' ); ?>
</span>
<?php } elseif ( ! $is_writable ) { ?>
<span class="sh-FileChannel-folderStatus--error">
<span class="dashicons dashicons-warning"></span>
<?php esc_html_e( 'Folder exists but is not writable. Check folder permissions.', 'simple-history' ); ?>
</span>
<?php } else { ?>
<span class="sh-FileChannel-folderStatus--success">
<span class="dashicons dashicons-yes-alt"></span>
<?php esc_html_e( 'Writable', 'simple-history' ); ?>
</span>
<?php if ( $stats && $stats['count'] > 0 ) { ?>
<span class="sh-FileChannel-folderStats">
<?php
echo esc_html(
sprintf(
/* translators: 1: number of files, 2: total size */
_n( '%1$s file', '%1$s files', $stats['count'], 'simple-history' ),
number_format_i18n( $stats['count'] )
)
);
?>
·
<?php echo esc_html( size_format( $stats['total_size'] ) ); ?>
<?php if ( $stats['oldest'] && $stats['newest'] ) { ?>
·
<?php
echo esc_html(
sprintf(
/* translators: 1: start date, 2: end date */
__( '%1$s – %2$s', 'simple-history' ),
wp_date( 'M j', $stats['oldest'] ),
wp_date( 'M j, Y', $stats['newest'] )
)
);
?>
<?php } ?>
</span>
<?php } ?>
<?php } ?>
</p>
<?php // Security note (inline, discrete). ?>
<p class="sh-FileChannel-securityNote">
<?php if ( $test_url ) { ?>
<?php esc_html_e( 'Folder is public.', 'simple-history' ); ?>
<a href="<?php echo esc_url( $test_url ); ?>" target="_blank" class="sh-ExternalLink">
<?php esc_html_e( 'Verify access is blocked', 'simple-history' ); ?>
</a>
<?php } else { ?>
<?php esc_html_e( 'Folder is outside the public web directory.', 'simple-history' ); ?>
<?php } ?>
</p>
</div>
<?php
}
/**
* Get the URL to the log directory for access testing.
*
* @return string|false The URL to the log directory, or false if not determinable.
*/
private function get_log_directory_url() {
$log_directory = $this->get_log_directory_path();
// Check if the log directory is inside ABSPATH (WordPress root).
// If so, it's potentially publicly accessible.
if ( strpos( $log_directory, ABSPATH ) !== 0 ) {
return false;
}
// Get the relative path from ABSPATH.
$relative_path = substr( $log_directory, strlen( ABSPATH ) );
// Build the URL.
return site_url( $relative_path );
}
/**
* Sanitize settings for this channel.
*
* @param array $input Raw input data from form submission.
* @return array Sanitized settings.
*/
public function sanitize_settings( $input ) {
// Get parent sanitization first.
$sanitized = parent::sanitize_settings( $input );
// Sanitize rotation frequency.
$valid_frequencies = [ 'daily', 'weekly', 'monthly' ];
$sanitized['rotation_frequency'] = in_array( $input['rotation_frequency'] ?? '', $valid_frequencies, true )
? $input['rotation_frequency']
: 'daily';
// Sanitize formatter.
$valid_formatters = array_keys( $this->get_available_formatters() );
$sanitized['formatter'] = in_array( $input['formatter'] ?? '', $valid_formatters, true )
? $input['formatter']
: 'human_readable';
// Sanitize keep files (integer between 1 and 365).
$keep_files = isset( $input['keep_files'] ) ? absint( $input['keep_files'] ) : 30;
$sanitized['keep_files'] = min( 365, max( 1, $keep_files ) );
return $sanitized;
}
/**
* Get the default settings for this channel.
*
* @return array Array of default settings.
*/
protected function get_default_settings() {
return array_merge(
parent::get_default_settings(),
[
'rotation_frequency' => 'daily',
'formatter' => 'human_readable',
'keep_files' => 30,
]
);
}
/**
* Get the log file path based on current settings.
*
* @return string|false Log file path or false on error.
*/
private function get_log_file_path() {
$log_dir = $this->get_log_directory_path();
$rotation = $this->get_setting( 'rotation_frequency', 'daily' );
$filename = $this->get_log_filename( $rotation );
if ( ! $filename ) {
return false;
}
return $log_dir . $filename;
}
/**
* Get the log directory path.
*
* Uses a hard-to-guess directory within the uploads folder for security
* and VIP compatibility. Returns path with trailing slash.
*
* @return string Log directory path with trailing slash.
*/
public function get_log_directory_path() {
// Use a random token for directory name (stored in settings for stability).
$folder_token = $this->get_folder_token();
// Use uploads directory for VIP compatibility.
$upload_dir = wp_get_upload_dir();
$default_directory = trailingslashit( $upload_dir['basedir'] ) . 'simple-history-logs-' . $folder_token;
/**
* Filter the log directory path.
*
* Allows customization of where log files are stored.
* For security, consider placing logs outside the public web directory.
*
* Example: Move logs outside the public web directory:
*
* add_filter( 'simple_history/file_channel/log_directory', function( $directory ) {
* return '/var/log/wordpress/simple-history';
* } );
*
* @since 5.6.0
*
* @param string $directory The log directory path.
*/
$log_directory = apply_filters( 'simple_history/file_channel/log_directory', $default_directory );
// Ensure the directory ends with a slash.
$log_directory = trailingslashit( $log_directory );
return $log_directory;
}
/**
* Get or create the folder token for log directory naming.
*
* Uses a stored random token instead of deriving from site config,
* ensuring the folder path remains stable even if auth keys change.
*
* Note: Stored in a separate option (not channel settings) to avoid
* conflicts with the WordPress Settings API sanitization flow.
*
* @return string 16-character alphanumeric token.
*/
private function get_folder_token() {
$option_name = 'simple_history_file_channel_folder_token';
$token = get_option( $option_name );
if ( ! $token ) {
$token = wp_generate_password( 16, false, false );
update_option( $option_name, $token, false );
}
/**
* Filter the folder token used for log directory naming.
*
* Allows customization of the folder token for multisite or custom deployments.
*
* @since 5.6.0
*
* @param string $token The 16-character folder token.
*/
$token = apply_filters( 'simple_history/file_channel/folder_token', $token );
// Sanitize for safe folder naming in case wp_generate_password() is
// overridden or filter returns unexpected characters.
$token = sanitize_file_name( $token );
return $token;
}
/**
* Get the log filename based on rotation frequency.
*
* Example formats:
* - Daily: events-2023-10-01.log
* - Weekly: events-2023-W40.log
* - Monthly: events-2023-10.log
*
* @param string $rotation Rotation frequency.
* @return string|false Log filename or false on error.
*/
private function get_log_filename( $rotation ) {
$base_name = 'events';
$extension = '.log';
switch ( $rotation ) {
case 'daily':
return $base_name . '-' . current_time( 'Y-m-d' ) . $extension;
case 'weekly':
return $base_name . '-' . current_time( 'Y' ) . '-W' . current_time( 'W' ) . $extension;
case 'monthly':
return $base_name . '-' . current_time( 'Y-m' ) . $extension;
default:
return false;
}
}
/**
* Ensure a directory exists and is writable.
*
* Creates security files (.htaccess and index.php) when creating a new directory.
*
* @param string $directory Directory path.
* @return bool True if directory exists and is writable.
*/
private function ensure_directory_exists( $directory ) {
if ( is_dir( $directory ) ) {
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_is_writable
return is_writable( $directory );
}
// Try to create the directory.
if ( ! wp_mkdir_p( $directory ) ) {
return false;
}
// Set appropriate permissions.
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.chmod_chmod
chmod( $directory, 0755 );
// Create security files.
$this->create_htaccess_file( $directory );
$this->create_index_file( $directory );
return true;
}
/**
* Create .htaccess file to protect log directory.
*
* @param string $directory Directory path.
*/
private function create_htaccess_file( $directory ) {
$htaccess_path = trailingslashit( $directory ) . '.htaccess';
// Only create if it doesn't exist.
if ( ! file_exists( $htaccess_path ) ) {
$htaccess_content = "# Simple History log directory protection\n\n";
$htaccess_content .= "# Apache 2.4+\n";
$htaccess_content .= "<IfModule mod_authz_core.c>\n";
$htaccess_content .= " Require all denied\n";
$htaccess_content .= "</IfModule>\n\n";
$htaccess_content .= "# Apache 2.2\n";
$htaccess_content .= "<IfModule !mod_authz_core.c>\n";
$htaccess_content .= " Order deny,allow\n";
$htaccess_content .= " Deny from all\n";
$htaccess_content .= "</IfModule>\n";
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents -- Direct file operations required for log directory protection.
file_put_contents( $htaccess_path, $htaccess_content );
}
}
/**
* Create index.php file to prevent directory listing.
*
* @param string $directory Directory path.
*/
private function create_index_file( $directory ) {
$index_path = trailingslashit( $directory ) . 'index.php';
// Only create if it doesn't exist.
if ( ! file_exists( $index_path ) ) {
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents -- Direct file operations required for log directory protection.
file_put_contents( $index_path, "<?php\n// Silence is golden.\n" );
}
}
/**
* Schedule cleanup if not already scheduled.
*/
private function schedule_cleanup_if_needed() {
$hook = 'simple_history_cleanup_log_files';
$args = [ $this->get_slug() ];
if ( wp_next_scheduled( $hook, $args ) ) {
return;
}
wp_schedule_single_event( time() + self::CLEANUP_DELAY_SECONDS, $hook, $args );
}
/**
* Clean up old log files based on current rotation frequency and keep settings.
*
* Only removes files that match the current rotation pattern.
*/
private function cleanup_old_files() {
$keep_files = $this->get_setting( 'keep_files', 30 );
$rotation = $this->get_setting( 'rotation_frequency', 'daily' );
$log_dir = $this->get_log_directory_path();
if ( ! is_dir( $log_dir ) ) {
return;
}
// Get files that match the current rotation pattern only.
$pattern = $this->get_cleanup_pattern( $rotation );
$log_files = glob( $log_dir . $pattern );
// glob() returns false on error, not an empty array.
if ( ! is_array( $log_files ) || count( $log_files ) <= $keep_files ) {
return;
}
// Sort by filename (which contains date) for consistent ordering.
sort( $log_files );
// Delete oldest files, keeping the most recent ones.
$files_to_delete = array_slice( $log_files, 0, count( $log_files ) - $keep_files );
foreach ( $files_to_delete as $file ) {
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_unlink -- Direct file operations required for log file rotation cleanup.
unlink( $file );
}
}
/**
* Get the glob pattern for cleanup based on rotation frequency.
*
* @param string $rotation The rotation frequency.
* @return string The glob pattern to match log files.
*/
private function get_cleanup_pattern( $rotation ) {
switch ( $rotation ) {
case 'daily':
// Matches files like events-2025-01-23.log.
return 'events-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].log*';
case 'weekly':
// Matches files like events-2025-W04.log.
return 'events-[0-9][0-9][0-9][0-9]-W[0-9][0-9].log*';
case 'monthly':
// Matches files like events-2025-01.log.
return 'events-[0-9][0-9][0-9][0-9]-[0-9][0-9].log*';
default:
// Fallback for any unexpected rotation values.
return 'events*.log*';
}
}
/**
* Handle async cleanup triggered by WordPress cron.
*
* @param string $channel_slug The channel slug to clean up for.
*/
public function handle_async_cleanup( $channel_slug ) {
// Only process if this is our channel.
if ( $channel_slug !== $this->get_slug() ) {
return;
}
$this->cleanup_old_files();
}
/**
* Get statistics about log files in the log directory.
*
* @return array{count: int, oldest: int|null, newest: int|null, total_size: int} File statistics.
*/
private function get_log_files_stats(): array {
$stats = [
'count' => 0,
'oldest' => null,
'newest' => null,
'total_size' => 0,
];
$log_dir = $this->get_log_directory_path();
if ( ! is_dir( $log_dir ) ) {
return $stats;
}
// Get all log files (any rotation pattern).
$log_files = glob( $log_dir . 'events-*.log*' );
if ( empty( $log_files ) ) {
return $stats;
}
$stats['count'] = count( $log_files );
// Get modification times for all files.
$file_times = [];
foreach ( $log_files as $file ) {
$mtime = filemtime( $file );
$file_times[ $file ] = $mtime;
$stats['total_size'] += filesize( $file );
}
// Find oldest and newest by modification time.
$stats['oldest'] = min( $file_times );
$stats['newest'] = max( $file_times );
return $stats;
}
}