20000, Plans::CREATOR => 20000, Plans::OPERATOR => 100000, Plans::FLEET => 100000, ); /** * Days a higher plan's history is kept after the site leaves that plan. */ public const GRACE_DAYS = 30; /** * Option holding the highest plan seen lately: plan id and the time its * grace runs out. */ private const KEEP_OPTION = 'gripmcp_log_keep'; /** * Longest summary of what a call did, in characters. */ private const SUMMARY_MAX = 500; private Settings $settings; public function __construct( Settings $settings ) { $this->settings = $settings; } /** * Fully-qualified table name. */ public static function table(): string { global $wpdb; return $wpdb->prefix . 'gripmcp_logs'; } /** * Create the log table on activation. */ public static function install_table(): void { global $wpdb; $table = self::table(); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, created_at DATETIME NOT NULL, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, method VARCHAR(64) NOT NULL DEFAULT '', tool VARCHAR(64) NOT NULL DEFAULT '', status VARCHAR(16) NOT NULL DEFAULT '', duration_ms INT UNSIGNED NOT NULL DEFAULT 0, ip VARCHAR(45) NOT NULL DEFAULT '', message TEXT NULL, PRIMARY KEY (id), KEY created_at (created_at), KEY user_id (user_id) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } /** * Log a tool call. * * @param string $tool Tool name. * @param Request_Context $context Context. * @param string $status ok|error|denied|pending|declined. * @param int $duration Duration in ms. * @param string $message Optional message. */ public function log_call( string $tool, Request_Context $context, string $status, int $duration, string $message = '' ): void { $this->insert( array( 'method' => 'tools/call', 'tool' => $tool, 'user_id' => $context->user_id(), 'status' => $status, 'duration_ms' => $duration, 'ip' => $context->get_ip(), 'message' => $message, ) ); } /** * What a call's result says it did, for its row: the one line its * progress-card step shows ("Updated post #12 ..."), with server paths * taken out and cut at SUMMARY_MAX characters. Values the tools hide from * the assistant (passwords, keys) are hidden there already. * * @param Tool_Result $result Result. */ public static function summary_of( Tool_Result $result ): string { $summary = trim( Support_Report::strip_roots( $result->get_summary() ) ); return mb_strlen( $summary ) > self::SUMMARY_MAX ? rtrim( mb_substr( $summary, 0, self::SUMMARY_MAX - 1 ) ) . '…' : $summary; } /** * Log an internal error against a method. * * @param string $method Method. * @param string $message Message. */ public function log_error( string $method, string $message ): void { $this->insert( array( 'method' => $method, 'status' => 'error', 'message' => $message, ) ); } /** * Fetch recent log rows for the admin UI, within the days this site * keeps. * * @param int $limit Maximum rows. * @return array> */ public function recent( int $limit = 25 ): array { global $wpdb; if ( ! $this->table_exists() ) { return array(); } $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE created_at >= %s ORDER BY id DESC LIMIT %d', $table, self::cutoff( self::retention_days() ), $limit ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * Days of activity log this site shows: its plan's (3 on Explorer, 7 on * Creator, 90 on Operator and Fleet), or fewer if a filter asks. */ public static function retention_days(): int { return self::limited( Plans::log_days() ); } /** * Days of activity log this site keeps in the table: what the plan the * rows are kept for keeps (kept_plan()), or fewer if a filter asks. */ public static function kept_days(): int { return self::limited( max( Plans::log_days(), Plans::log_days( self::kept_plan() ) ) ); } /** * Most rows a plan keeps. * * @param string|null $plan Plan id; the site's current plan when null. */ public static function max_rows( ?string $plan = null ): int { return self::MAX_ROWS[ $plan ?? Plans::current() ] ?? self::MAX_ROWS[ Plans::EXPLORER ]; } /** * The plan rows are kept for: the site's plan, or a higher one it had in * the last GRACE_DAYS. While the site's plan is the highest, its grace is * renewed, at most once a day so a busy site writes no option per call. */ public static function kept_plan(): string { $current = Plans::current(); $stored = get_option( self::KEEP_OPTION, array() ); $plan = is_array( $stored ) && isset( $stored['plan'] ) ? (string) $stored['plan'] : ''; $until = is_array( $stored ) && isset( $stored['until'] ) ? (int) $stored['until'] : 0; $now = time(); $rank = array_flip( Plans::ORDER ); $held = isset( $rank[ $plan ] ) && $until > $now; if ( $held && $rank[ $plan ] > $rank[ $current ] ) { return $plan; } if ( Plans::EXPLORER === $current ) { // Nothing longer to remember; forget a grace that has run out. if ( '' !== $plan && ! $held ) { delete_option( self::KEEP_OPTION ); } } elseif ( $plan !== $current || $until - $now < ( self::GRACE_DAYS - 1 ) * DAY_IN_SECONDS ) { update_option( self::KEEP_OPTION, array( 'plan' => $current, 'until' => $now + self::GRACE_DAYS * DAY_IN_SECONDS, ), false ); } return $current; } /** * While older rows are kept only for a higher plan the site no longer * has: that plan, the days it keeps, and when they go. Null otherwise. * * @return array{plan: string, days: int, until: int}|null */ public static function grace(): ?array { $plan = self::kept_plan(); $days = self::kept_days(); if ( $plan === Plans::current() || $days <= self::retention_days() ) { return null; } $stored = get_option( self::KEEP_OPTION, array() ); return array( 'plan' => $plan, 'days' => $days, 'until' => is_array( $stored ) ? (int) ( $stored['until'] ?? 0 ) : 0, ); } /** * Days, after the site's own filter. * * @param int $days Days a plan keeps. */ private static function limited( int $days ): int { /** * Filters how many days of activity log are kept. * * A site can keep fewer days than its plan does, never more. * * @param int $days Days the site's plan keeps. */ $asked = (int) apply_filters( 'gripmcp/log_retention_days', $days ); return $asked > 0 && $asked < $days ? $asked : $days; } /** * The oldest created_at a row kept for some days can have. created_at is * written in site time (current_time( 'mysql' )), and so is this. * * @param int $days Days kept. */ public static function cutoff( int $days ): string { return (string) wp_date( 'Y-m-d H:i:s', time() - max( 1, $days ) * DAY_IN_SECONDS ); } /** * Delete the rows this site no longer keeps. Run by the daily clean-up. */ public function prune_expired(): void { if ( $this->table_exists() ) { $this->prune( 0 ); } } /** * Insert a row if logging is enabled and the table exists. * * @param array $data Row data. */ private function insert( array $data ): void { if ( ! $this->settings->get( 'logging_enabled', true ) || ! $this->table_exists() ) { return; } global $wpdb; $row = wp_parse_args( $data, array( 'created_at' => current_time( 'mysql' ), 'user_id' => 0, 'method' => '', 'tool' => '', 'status' => '', 'duration_ms' => 0, 'ip' => '', 'message' => '', ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->insert( self::table(), $row, array( '%s', '%d', '%s', '%s', '%s', '%d', '%s', '%s' ) ); // Every call adds a row, so an assistant that works on the site every // day would grow the table without end. Every hundredth row, older // entries go. $id = (int) $wpdb->insert_id; if ( $id > 0 && 0 === $id % 100 ) { $this->prune( $id ); } } /** * Drop rows older than the days this site keeps (kept_days()) and * anything beyond the newest rows the kept plan allows. * * @param int $last_id Id of the row just written (0 when none was). */ private function prune( int $last_id ): void { global $wpdb; $table = self::table(); $cap = self::max_rows( self::kept_plan() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s', $table, self::cutoff( self::kept_days() ) ) ); if ( $last_id > $cap ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id <= %d', $table, $last_id - $cap ) ); } } /** * Whether the log table exists (cached per request). */ private function table_exists(): bool { static $exists = null; if ( null !== $exists ) { return $exists; } global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); $exists = ( $found === $table ); return $exists; } }