*/ public const LEVELS = [ self::EMERGENCY => 'emergency', self::ERROR => 'error', self::WARNING => 'warning', self::INFO => 'info', self::DEBUG => 'debug', ]; /** * Rows waiting to be written. * * @var list */ private static array $buffer = []; /** * Whether the end-of-request flush has been hooked yet. * * @var bool */ private static bool $flushHooked = false; /** * Highest level that gets written, resolved once per request. * * @var int|null */ private static ?int $threshold = null; /** * Log an error. * * @param string $channel Subsystem, e.g. `queue` or `sms`. * @param string $message Human-readable message. * @param array $context Structured detail. */ public static function error( string $channel, string $message, array $context = [] ): void { self::log( self::ERROR, $channel, $message, $context ); } /** * Log a warning. * * @param string $channel Subsystem. * @param string $message Human-readable message. * @param array $context Structured detail. */ public static function warning( string $channel, string $message, array $context = [] ): void { self::log( self::WARNING, $channel, $message, $context ); } /** * Log an informational event. * * @param string $channel Subsystem. * @param string $message Human-readable message. * @param array $context Structured detail. */ public static function info( string $channel, string $message, array $context = [] ): void { self::log( self::INFO, $channel, $message, $context ); } /** * Log a debug event. * * @param string $channel Subsystem. * @param string $message Human-readable message. * @param array $context Structured detail. */ public static function debug( string $channel, string $message, array $context = [] ): void { self::log( self::DEBUG, $channel, $message, $context ); } /** * Buffer a log line. * * @param int $level One of the level constants. * @param string $channel Subsystem. * @param string $message Human-readable message. * @param array $context Structured detail. */ public static function log( int $level, string $channel, string $message, array $context = [] ): void { if ( $level > self::threshold() ) { return; } self::$buffer[] = [ 'level' => $level, 'channel' => substr( $channel, 0, 32 ), 'message' => substr( $message, 0, 500 ), 'context' => self::encodeContext( $context ), 'created_at' => current_time( 'mysql', true ), ]; if ( ! self::$flushHooked ) { self::$flushHooked = true; add_action( 'shutdown', [ self::class, 'flush' ], 100 ); } } /** * Write the buffer as one insert. * * @internal Hooked callback. */ public static function flush(): void { if ( [] === self::$buffer ) { return; } $rows = self::$buffer; // Cleared before the write so a failure cannot cause a retry loop. self::$buffer = []; global $wpdb; $table = $wpdb->prefix . 'rahkar_logs'; $placeholders = []; $values = []; foreach ( $rows as $row ) { $placeholders[] = '(%d, %s, %s, %s, %s)'; array_push( $values, $row['level'], $row['channel'], $row['message'], $row['context'], $row['created_at'] ); } $sql = "INSERT INTO `{$table}` (`level`, `channel`, `message`, `context`, `created_at`) VALUES " . implode( ', ', $placeholders ); // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->suppress_errors( true ); $written = $wpdb->query( $wpdb->prepare( $sql, $values ) ); $wpdb->suppress_errors( false ); // phpcs:enable, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( false === $written ) { self::fallback( $rows ); } } /** * Resolve the active log level. * * Read from the plugin's own settings rather than `WP_DEBUG` so a site can run * with debug off and still keep an audit trail of SMS sends and queue failures * — which is usually what a store actually needs. */ private static function threshold(): int { if ( null !== self::$threshold ) { return self::$threshold; } $configured = Settings::get( 'core.log_level' ); if ( null === $configured ) { $configured = ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ? self::DEBUG : self::INFO; } self::$threshold = (int) $configured; return self::$threshold; } /** * JSON-encode context, keeping the column bounded. * * @param array $context Structured detail. */ private static function encodeContext( array $context ): string { if ( [] === $context ) { return ''; } $json = wp_json_encode( $context, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR ); if ( ! is_string( $json ) ) { return ''; } return substr( $json, 0, 2000 ); } /** * Last resort when the log table is unavailable. * * @param list $rows Buffered rows. */ private static function fallback( array $rows ): void { foreach ( $rows as $row ) { $level = self::LEVELS[ $row['level'] ] ?? 'info'; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( sprintf( '[rahkar-wp][%s][%s] %s %s', $level, $row['channel'], $row['message'], $row['context'] ) ); } } }