prepare() * cannot bind identifiers, only values. * * @return string */ public static function table() { global $wpdb; return $wpdb->base_prefix . self::TABLE; } /** * Creates or updates the table. * * Called on activation, and again from maybe_install() when the stored * schema version does not match - activation hooks do not run on a plugin * update, which is the same reason boot-data.json is repaired on wp_loaded. * * @return bool Whether the table is usable afterwards. */ public static function install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $collate = $wpdb->get_charset_collate(); /* * dbDelta is picky about this string: two spaces after PRIMARY KEY, one * field per line, key names spelled out. It is not ordinary SQL, it is * parsed by a regular expression. */ $sql = "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, logged_at datetime NOT NULL, level varchar(10) NOT NULL, code varchar(64) NOT NULL, user_id bigint(20) unsigned NOT NULL DEFAULT 0, actor varchar(60) NOT NULL DEFAULT '', message text NOT NULL, repeat_count int(10) unsigned NOT NULL DEFAULT 1, last_repeat_at datetime DEFAULT NULL, PRIMARY KEY (id), KEY logged_at (logged_at), KEY code (code) ) {$collate};"; /* * Errors are hidden for the duration, and this is not cosmetic. With * WP_DEBUG and WP_DEBUG_DISPLAY both on - class-wpdb.php:759 - wpdb * prints failed queries straight to the output, and a host that refuses * CREATE TABLE makes this one fail. Any output during activation turns * into "The plugin generated N characters of unexpected output", so a * missing journal would look like a broken plugin. The text is not lost: * print_error() writes it to error_log() regardless of display. */ $show = $wpdb->hide_errors(); dbDelta( $sql ); $wpdb->show_errors( $show ); $ready = self::table_exists(); DLOGR_Settings::set_state( 'journal_ready', $ready ); if ( $ready ) { DLOGR_Settings::set_state( 'journal_schema', self::SCHEMA_VERSION ); } return $ready; } /** * Builds the table when it is missing or outdated. * * Hooked to admin_init. Retries are throttled to once an hour: on a host * where the database user may not create tables this would otherwise run * dbDelta - several DESCRIBE queries - on every admin page load, forever. * Throttled rather than given up on, because the permission may be granted * later and the plugin should then heal without being reactivated. * * @return void */ public static function maybe_install() { $current = (int) DLOGR_Settings::get_state( 'journal_schema' ); if ( self::SCHEMA_VERSION === $current && self::is_ready() ) { return; } if ( get_transient( self::INSTALL_LOCK ) ) { return; } set_transient( self::INSTALL_LOCK, 1, HOUR_IN_SECONDS ); self::install(); } /** * Whether the journal can be written to. * * @return bool */ public static function is_ready() { return (bool) DLOGR_Settings::get_state( 'journal_ready' ); } /** * Records a failure. * * @param string $code Machine-readable event code. * @param string $message Text for a human, English. * @return void */ public static function error( $code, $message ) { self::add( 'error', $code, $message ); } /** * Records something the plugin did. * * @param string $code Machine-readable event code. * @param string $message Text for a human, English. * @return void */ public static function info( $code, $message ) { self::add( 'info', $code, $message ); } /** * Writes one entry, collapsing an immediate repeat into a counter. * * Deduplication is not an optimisation but a requirement: with wp-content * unwritable the repair produces the same failure over and over, and without * collapsing it the journal would drown itself and become worse than no * journal at all. * * Only an immediate repeat collapses - the last row, not any row with the * same code. Two separate outbreaks of one failure, with normal work in * between, are two events in the history and must stay two rows. * * ⚠️ The actor is part of what makes a repeat a repeat. Collapsing on the * code alone would let two administrators who did the same thing one after * another become one row bearing the first one's name - the journal would * not just lose an entry, it would state something untrue. * * ⚠️ So is the text. Two different events under one code, one after the * other from the same person, would otherwise become one row holding the * first one's message - and the codes this matters for are the ones the * journal is kept for: dir_created is how a directory with a random suffix * is recognised a year later. "The same" therefore means literally the * same: code, actor and message. * * @param string $level Either error or info. * @param string $code Machine-readable event code. * @param string $message Text for a human, English. * @return void */ private static function add( $level, $code, $message ) { global $wpdb; if ( ! self::is_ready() ) { return; } $table = self::table(); $code = substr( (string) $code, 0, 64 ); $message = (string) $message; $now = current_time( 'mysql', true ); $who = self::actor(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name comes from $wpdb->base_prefix and a class constant, and prepare() cannot bind identifiers. Own table, no core API for it, and caching a write-ordered journal would defeat the point. $last = $wpdb->get_row( "SELECT id, code, user_id, message FROM {$table} ORDER BY id DESC LIMIT 1" ); if ( $last && $last->code === $code && (int) $last->user_id === $who['user_id'] && $last->message === $message ) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name comes from $wpdb->base_prefix and a class constant, and prepare() cannot bind identifiers. See above. $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name, see table(); the values are bound. "UPDATE {$table} SET repeat_count = repeat_count + 1, last_repeat_at = %s WHERE id = %d", $now, $last->id ) ); return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name comes from $wpdb->base_prefix and a class constant, and prepare() cannot bind identifiers. See above. $wpdb->insert( $table, array( 'logged_at' => $now, 'level' => 'error' === $level ? 'error' : 'info', 'code' => $code, 'user_id' => $who['user_id'], 'actor' => $who['actor'], 'message' => $message, 'repeat_count' => 1, ), array( '%s', '%s', '%s', '%d', '%s', '%s', '%d' ) ); self::enforce_ceiling(); } /** * Who is doing this. * * Half the journal is written with no human anywhere near: the repair runs * on wp_loaded, so it can happen on an anonymous visitor's request, in cron * or under WP-CLI. A column that answered only for people would be empty * exactly where the interesting failures are, and an empty field cannot be * told from one nobody filled in. So the non-human cases get names too. * * Both an id and a login are kept, and neither replaces the other: delete * the user and an id alone becomes "user 7", which says nothing a year * later - and a year later is what the journal is for. A login alone cannot * tell two similar accounts apart or survive a rename. * * ⚠️ cron is checked before WP-CLI, matching the order in boot.php: running * cron through WP-CLI satisfies both, and the plugin must not call the same * event cron in one place and cli in another. * * @return array Keys user_id and actor. */ private static function actor() { $user_id = get_current_user_id(); if ( $user_id > 0 ) { $user = get_userdata( $user_id ); $login = ( $user && '' !== $user->user_login ) ? $user->user_login : 'user-' . $user_id; return array( 'user_id' => (int) $user_id, 'actor' => substr( $login, 0, 60 ), ); } if ( wp_doing_cron() ) { return array( 'user_id' => 0, 'actor' => 'cron', ); } if ( defined( 'WP_CLI' ) && WP_CLI ) { return array( 'user_id' => 0, 'actor' => 'cli', ); } return array( 'user_id' => 0, 'actor' => 'system', ); } /** * Drops the oldest rows once the ceiling is passed. * * The ceiling is the only safeguard today: export to an archive file is * postponed together with log rotation. And even once it comes, it cannot * close this - exporting needs a writable log directory, and the main * scenario here is that there is none. Without a hard limit the table would * grow without bound in somebody else's database. Losing the oldest entries * of a malfunctioning plugin is an acceptable price; an unbounded table is * not. * * Only runs after an insert: a collapsed repeat adds no row. * * @return void */ private static function enforce_ceiling() { global $wpdb; $table = self::table(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name comes from $wpdb->base_prefix and a class constant, and prepare() cannot bind identifiers. Own table, and the count must be current. $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); if ( $total <= self::MAX_ROWS ) { return; } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name comes from $wpdb->base_prefix and a class constant, and prepare() cannot bind identifiers. See above. $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name, see table(); the limit is bound. "DELETE FROM {$table} ORDER BY id ASC LIMIT %d", $total - self::MAX_ROWS ) ); } /** * Most recent entries, newest first. * * @param int $limit How many at most. * @return array Rows as objects, empty when the journal is unavailable. */ public static function recent( $limit = 50 ) { global $wpdb; if ( ! self::is_ready() ) { return array(); } $table = self::table(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name comes from $wpdb->base_prefix and a class constant, and prepare() cannot bind identifiers. Own table, and the journal is read to be shown as it is right now. $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name, see table(); the limit is bound. "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d", max( 1, (int) $limit ) ) ); return is_array( $rows ) ? $rows : array(); } /** * One helping for the page, newest first, below a frozen boundary. * * ⚠️ The boundary is an id rather than a byte offset, and that is the whole * difference from the way log files are paged. The purpose is the same: * everything written while somebody reads sits above the boundary and stays * out of the listing, so asking for more never makes the rows already on * screen shift or repeat. What costs an fseek to the end of a file costs a * WHERE here. * * @param int $before Highest id to include; 0 for the newest there is. * @param int $limit How many rows at most. * @return array Rows as objects, empty when the journal is unavailable. */ public static function page( $before = 0, $limit = 50 ) { global $wpdb; if ( ! self::is_ready() ) { return array(); } $table = self::table(); $limit = max( 1, (int) $limit ); $before = max( 0, (int) $before ); if ( $before > 0 ) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Own table, read to be shown as it is right now; the name comes from base_prefix and a class constant, and prepare() cannot bind identifiers. $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name, see table(); both values are bound. "SELECT * FROM {$table} WHERE id <= %d ORDER BY id DESC LIMIT %d", $before, $limit ) ); return is_array( $rows ) ? $rows : array(); } return self::recent( $limit ); } /** * Whether anything older than this row is still kept. * * One indexed row rather than a count: the page only needs to know whether * to offer the link, not how many are behind it. * * @param int $id Oldest id already on screen. * @return bool */ public static function has_older( $id ) { global $wpdb; if ( ! self::is_ready() || $id <= 1 ) { return false; } $table = self::table(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Own table, asked about its state right now; see the note in page(). $found = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name, see table(); the value is bound. "SELECT id FROM {$table} WHERE id < %d ORDER BY id DESC LIMIT 1", (int) $id ) ); return null !== $found; } /** * A batch in writing order, for the export. * * ⚠️ Oldest first here, unlike everything else in this class. A file that * reads like a log has to grow downwards the way a log does, and our own * reader walks it from the end backwards on that assumption. * * Batches rather than one SELECT over the table: the ceiling is ours and * small today, but it is aimed at a setting with ten thousand as its top * end, and loading everything into memory is the habit this plugin avoids * everywhere else. * * @param int $after Highest id already taken; 0 to start. * @param int $limit How many rows at most. * @return array Rows as objects, oldest first. */ public static function ascending( $after = 0, $limit = 200 ) { global $wpdb; if ( ! self::is_ready() ) { return array(); } $table = self::table(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Own table, exported as it is right now; see the note in page(). $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name, see table(); both values are bound. "SELECT * FROM {$table} WHERE id > %d ORDER BY id ASC LIMIT %d", max( 0, (int) $after ), max( 1, (int) $limit ) ) ); return is_array( $rows ) ? $rows : array(); } /** * How many entries are stored. * * @return int */ public static function count() { global $wpdb; if ( ! self::is_ready() ) { return 0; } $table = self::table(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name comes from $wpdb->base_prefix and a class constant, and prepare() cannot bind identifiers. Own table. return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); } /** * Removes the table. * * For uninstall only, and only once the block has been taken out of * wp-config.php: the journal holds the entries explaining why an uninstall * went wrong, and those are wanted exactly then. * * ⚠️ The two flags are cleared only when the table really went (2026-09-05). * They gate every write in add(), so clearing them after a failed DROP * would silence the journal at the one moment it still exists and still * has something to say: a DROP that did not work means the table is right * there, ready to be told so. * * @return bool Whether the table is gone. */ public static function drop() { global $wpdb; $table = self::table(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Removing our own table on uninstall; the name is built from the prefix and a class constant. Removing our own table on uninstall; there is no core API for it. $dropped = $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); if ( false === $dropped ) { return false; } DLOGR_Settings::set_state( 'journal_ready', false ); DLOGR_Settings::set_state( 'journal_schema', 0 ); return true; } /** * Whether the table is really there. * * Asked of the database rather than taken from an option: dbDelta reports * nothing useful when CREATE TABLE is refused, and a stored flag saying the * journal works while it does not is worse than no journal. * * @return bool */ private static function table_exists() { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name comes from $wpdb->base_prefix and a class constant, and prepare() cannot bind identifiers. Checking for our own table; the answer must be current. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); return $found === $table; } }