schema = $schema; } /** * Logs an AI request. * * @since 1.0.0 * * @param array{ * type: string, * operation: string, * provider?: string, * model?: string, * duration_ms?: int, * tokens_input?: int, * tokens_output?: int, * status: string, * error_message?: string, * user_id?: int, * context?: array * } $data Log data. * @return string|false The log ID on success, false on failure. */ public function insert( array $data ) { global $wpdb; $log_id = wp_generate_uuid4(); $tokens_total = ( $data['tokens_input'] ?? 0 ) + ( $data['tokens_output'] ?? 0 ); $context = $data['context'] ?? array(); $request_preview = $context['input_preview'] ?? null; $response_preview = $context['output_preview'] ?? null; $insert_data = array( 'log_id' => $log_id, 'timestamp' => current_time( 'mysql', true ), 'type' => $data['type'], 'operation' => $data['operation'], 'provider' => $data['provider'] ?? null, 'model' => $data['model'] ?? null, 'duration_ms' => $data['duration_ms'] ?? null, 'tokens_input' => $data['tokens_input'] ?? null, 'tokens_output' => $data['tokens_output'] ?? null, 'tokens_total' => $tokens_total > 0 ? $tokens_total : null, 'status' => $data['status'], 'error_message' => $data['error_message'] ?? null, 'user_id' => $data['user_id'] ?? get_current_user_id(), 'context' => ! empty( $context ) ? wp_json_encode( $context ) : null, 'request_preview' => $request_preview, 'response_preview' => $response_preview, ); $result = $wpdb->insert( $this->schema->get_table_name(), $insert_data, array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%d', '%s', '%s', '%s' ) ); if ( false === $result ) { return false; } $this->invalidate_summary_cache(); $this->invalidate_filter_cache(); /** * Fires after an AI request is logged. * * @since 1.0.0 * * @param string $log_id The unique log identifier. * @param array $data The log data. */ do_action( 'wpai_request_logged', $log_id, $insert_data ); return $log_id; } /** * Retrieves a single log entry by ID. * * @since 1.0.0 * * @param string $log_id The log identifier. * @return array|null The log entry or null if not found. */ public function find( string $log_id ): ?array { global $wpdb; $table_name = $this->schema->get_table_name(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table_name} WHERE log_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $log_id ), ARRAY_A ); if ( ! $row ) { return null; } return $this->format_log_row( $row ); } /** * Retrieves logs with filtering and pagination. * * @since 1.0.0 * * @param array{ * type?: string, * status?: string, * provider?: string, * operation?: string, * user_id?: int, * date_from?: string, * date_to?: string, * search?: string, * page?: int, * per_page?: int, * orderby?: string, * order?: string, * cursor_id?: int, * cursor_timestamp?: string * } $args Query arguments. * @return array{items: list>, total: int, pages: int, next_cursor?: array{id: int, timestamp: string}} Results. */ public function query( array $args = array() ): array { global $wpdb; $defaults = array( 'type' => '', 'status' => '', 'provider' => '', 'operation' => '', 'tokens_filter' => '', 'user_id' => 0, 'date_from' => '', 'date_to' => '', 'search' => '', 'page' => 1, 'per_page' => 25, 'orderby' => 'timestamp', 'order' => 'DESC', 'cursor_id' => null, 'cursor_timestamp' => null, ); $args = wp_parse_args( $args, $defaults ); $use_cursor = 'timestamp' === $args['orderby'] && null !== $args['cursor_id'] && null !== $args['cursor_timestamp']; $table_name = $this->schema->get_table_name(); $where = array( '1=1' ); $values = array(); $this->build_where_clauses( $args, $where, $values ); $where_clause = implode( ' AND ', $where ); $allowed_orderby = array( 'timestamp', 'type', 'operation', 'duration_ms', 'tokens_total', 'status' ); $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'timestamp'; $order = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC'; $count_sql = "SELECT COUNT(*) FROM {$table_name} WHERE {$where_clause}"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( ! empty( $values ) ) { $count_sql = $wpdb->prepare( $count_sql, $values ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } $total = (int) $wpdb->get_var( $count_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $per_page = max( 1, min( 100, (int) $args['per_page'] ) ); $pages = (int) ceil( $total / $per_page ); $page = max( 1, min( ( $pages ? $pages : 1 ), (int) $args['page'] ) ); if ( $use_cursor ) { $rows = $this->query_with_cursor( $table_name, $where_clause, $values, $args, $orderby, $order, $per_page ); } else { $rows = $this->query_with_offset( $table_name, $where_clause, $values, $orderby, $order, $per_page, $page ); } $items = array_map( array( $this, 'format_log_row' ), ( $rows ? $rows : array() ) ); $result = array( 'items' => $items, 'total' => $total, 'pages' => max( 1, $pages ), ); if ( ! empty( $rows ) ) { $last_row = end( $rows ); $result['next_cursor'] = array( 'id' => (int) $last_row['id'], 'timestamp' => $last_row['timestamp'], ); } return $result; } /** * Gets aggregate statistics for the dashboard. * * @since 1.0.0 * * @param string $period Time period: 'day', 'week', 'month', or 'all'. * @param bool $force_refresh Whether to bypass the cache. * @return array{ * total_requests: int, * total_tokens: int, * avg_duration_ms: float, * success_rate: float, * by_type: array, * by_provider: array, * by_status: array * } Aggregated statistics. */ public function get_summary( string $period = 'day', bool $force_refresh = false ): array { $cache_key = self::CACHE_GROUP . '_summary_' . $period; if ( ! $force_refresh ) { $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { /** @var array{total_requests: int, total_tokens: int, avg_duration_ms: float, success_rate: float, by_type: array, by_provider: array, by_status: array} $cached */ return $cached; } } global $wpdb; $table_name = $this->schema->get_table_name(); $date_condition = $this->get_date_condition( $period ); $sql = "SELECT COUNT(*) as total_requests, COALESCE(SUM(tokens_total), 0) as total_tokens, COALESCE(AVG(duration_ms), 0) as avg_duration_ms, SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count, type, provider, status FROM {$table_name} WHERE 1=1 {$date_condition} GROUP BY type, provider, status"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $result = $this->aggregate_summary_rows( ( $rows ? $rows : array() ) ); set_transient( $cache_key, $result, self::SUMMARY_CACHE_EXPIRATION ); return $result; } /** * Gets distinct values for filter dropdowns. * * @since 1.0.0 * * @param bool $force_refresh Whether to bypass the cache. * @return array{types: list, providers: list, statuses: list, operations: list, user_ids: list} Filter options. */ public function get_filter_options( bool $force_refresh = false ): array { $cache_key = self::CACHE_GROUP . '_filter_options'; if ( ! $force_refresh ) { $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } } global $wpdb; $table_name = $this->schema->get_table_name(); $sql = "SELECT 'type' as category, type as value FROM {$table_name} WHERE type IS NOT NULL GROUP BY type UNION ALL SELECT 'provider' as category, provider as value FROM {$table_name} WHERE provider IS NOT NULL GROUP BY provider UNION ALL SELECT 'status' as category, status as value FROM {$table_name} WHERE status IS NOT NULL GROUP BY status UNION ALL SELECT 'operation' as category, operation as value FROM {$table_name} WHERE operation IS NOT NULL GROUP BY operation UNION ALL SELECT 'user' as category, user_id as value FROM {$table_name} WHERE user_id IS NOT NULL AND user_id > 0 GROUP BY user_id"; $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $result = $this->aggregate_filter_options( ( $rows ? $rows : array() ) ); set_transient( $cache_key, $result, self::FILTER_CACHE_EXPIRATION ); return $result; } /** * Deletes logs older than the retention period. * * @since 1.0.0 * * @param int $retention_days Number of days to retain logs. * @return int Number of logs deleted. */ public function cleanup_by_retention( int $retention_days ): int { // Retention <= 0 means logs are kept indefinitely. if ( $retention_days <= 0 ) { return 0; } global $wpdb; $table_name = $this->schema->get_table_name(); $total_deleted = 0; do { $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table_name} WHERE timestamp < DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY) LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $retention_days, self::DELETE_BATCH_SIZE ) ); $batch_deleted = ( $deleted ? $deleted : 0 ); $total_deleted += $batch_deleted; if ( $batch_deleted < self::DELETE_BATCH_SIZE ) { continue; } usleep( 100000 ); } while ( $batch_deleted >= self::DELETE_BATCH_SIZE ); return $total_deleted; } /** * Purges all logs from the database. * * @since 1.0.0 * * @return int Number of logs deleted. */ public function purge_all(): int { global $wpdb; $table_name = $this->schema->get_table_name(); $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "TRUNCATE TABLE {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $this->invalidate_caches(); return $count; } /** * Invalidates all caches. * * @since 1.0.0 */ public function invalidate_caches(): void { $this->invalidate_filter_cache(); $this->invalidate_summary_cache(); } /** * Invalidates the filter options cache. * * @since 1.0.0 */ public function invalidate_filter_cache(): void { delete_transient( self::CACHE_GROUP . '_filter_options' ); } /** * Invalidates the summary cache. * * @since 1.0.0 */ public function invalidate_summary_cache(): void { $periods = array( 'minute', 'hour', 'day', 'week', 'month', 'all' ); foreach ( $periods as $period ) { delete_transient( self::CACHE_GROUP . '_summary_' . $period ); } } /** * Builds WHERE clauses for the query. * * @since 1.0.0 * * @param array $args Query arguments. * @param list $where WHERE clauses array (modified by reference). * @param list $values Values array for prepared statement (modified by reference). */ private function build_where_clauses( array $args, array &$where, array &$values ): void { if ( ! empty( $args['type'] ) ) { $where[] = 'type = %s'; $values[] = $args['type']; } if ( ! empty( $args['status'] ) ) { $where[] = 'status = %s'; $values[] = $args['status']; } if ( ! empty( $args['provider'] ) ) { $where[] = 'provider = %s'; $values[] = $args['provider']; } if ( ! empty( $args['operation'] ) ) { $operations = array_filter( array_map( 'trim', explode( ',', $args['operation'] ) ) ); if ( 1 === count( $operations ) ) { $where[] = 'operation = %s'; $values[] = $operations[0]; } elseif ( count( $operations ) > 1 ) { $placeholders = implode( ', ', array_fill( 0, count( $operations ), '%s' ) ); $where[] = "operation IN ( $placeholders )"; $values = array_merge( $values, $operations ); } } $this->build_token_filter_clauses( $args, $where, $values ); if ( ! empty( $args['user_id'] ) ) { $where[] = 'user_id = %d'; $values[] = $args['user_id']; } if ( ! empty( $args['date_from'] ) ) { $where[] = 'timestamp >= %s'; $values[] = $args['date_from']; } if ( ! empty( $args['date_to'] ) ) { $where[] = 'timestamp <= %s'; $values[] = $args['date_to']; } if ( empty( $args['search'] ) ) { return; } $this->build_search_clause( $args['search'], $where, $values ); } /** * Builds token filter clauses. * * @since 1.0.0 * * @param array $args Query arguments. * @param list $where WHERE clauses array (modified by reference). * @param list $values Values array (modified by reference). */ private function build_token_filter_clauses( array $args, array &$where, array &$values ): void { if ( empty( $args['tokens_filter'] ) ) { return; } $filter = $args['tokens_filter']; if ( 'none' === $filter ) { $where[] = '(tokens_total IS NULL OR tokens_total = 0)'; } elseif ( 0 === strpos( $filter, 'gt:' ) ) { $where[] = 'tokens_total > %d'; $values[] = (int) substr( $filter, 3 ); } elseif ( 0 === strpos( $filter, 'lt:' ) ) { $where[] = 'tokens_total < %d'; $values[] = (int) substr( $filter, 3 ); } } /** * Builds the search clause for full-text or LIKE search. * * @since 1.0.0 * * @param string $search Search term. * @param list $where WHERE clauses array (modified by reference). * @param list $values Values array (modified by reference). */ private function build_search_clause( string $search, array &$where, array &$values ): void { global $wpdb; $search_like = '%' . $wpdb->esc_like( $search ) . '%'; // Only use fulltext if index exists AND autocommit is enabled (tests use transactions). $use_fulltext = $this->schema->has_fulltext_index() && $this->is_autocommit_enabled(); if ( $use_fulltext ) { $boolean_query = $this->build_fulltext_search_query( $search ); if ( '' !== $boolean_query ) { $where[] = '(MATCH(operation, request_preview, response_preview) AGAINST(%s IN BOOLEAN MODE) OR error_message LIKE %s)'; $values[] = $boolean_query; $values[] = $search_like; return; } } $where[] = '(operation LIKE %s OR error_message LIKE %s OR request_preview LIKE %s OR response_preview LIKE %s)'; $values[] = $search_like; $values[] = $search_like; $values[] = $search_like; $values[] = $search_like; } /** * Builds a boolean-mode FULLTEXT query string. * * @since 1.0.0 * * @param string $search Raw search string. * @return string Boolean FULLTEXT query or empty string. */ private function build_fulltext_search_query( string $search ): string { $search = trim( $search ); if ( '' === $search ) { return ''; } $tokens = preg_split( '/\s+/', $search ); if ( ! $tokens ) { return ''; } $clauses = array(); foreach ( $tokens as $token ) { $token = trim( (string) $token ); if ( '' === $token ) { continue; } $token = preg_replace( '/[+\-><()~*"@]+/', ' ', $token ); $token = trim( (string) $token ); if ( '' === $token ) { continue; } $length = function_exists( 'mb_strlen' ) ? mb_strlen( $token, 'UTF-8' ) : strlen( $token ); if ( $length < 3 ) { continue; } $clauses[] = '+' . $token . '*'; } return implode( ' ', $clauses ); } /** * Determines if the current database connection has autocommit enabled. * * In transactional test environments (e.g., WP_UnitTestCase), fulltext indexes * are not updated until commit, so prefer LIKE queries when autocommit is off. * * @since 1.0.0 * * @return bool True if autocommit is enabled. */ private function is_autocommit_enabled(): bool { global $wpdb; $autocommit = $wpdb->get_var( 'SELECT @@autocommit' ); if ( null === $autocommit ) { return true; } return 1 === (int) $autocommit; } /** * Queries logs using cursor-based pagination. * * @since 1.0.0 * * @param string $table_name Table name. * @param string $where_clause WHERE clause. * @param list $values Prepared values. * @param array $args Query arguments. * @param string $orderby Order by column. * @param string $order Sort order. * @param int $per_page Results per page. * @return list> Query results. */ private function query_with_cursor( string $table_name, string $where_clause, array $values, array $args, string $orderby, string $order, int $per_page ): array { global $wpdb; $cursor_values = $values; if ( 'DESC' === $order ) { $cursor_values[] = $args['cursor_timestamp']; $cursor_values[] = $args['cursor_timestamp']; $cursor_values[] = (int) $args['cursor_id']; $cursor_condition = '((timestamp < %s) OR (timestamp = %s AND id < %d))'; } else { $cursor_values[] = $args['cursor_timestamp']; $cursor_values[] = $args['cursor_timestamp']; $cursor_values[] = (int) $args['cursor_id']; $cursor_condition = '((timestamp > %s) OR (timestamp = %s AND id > %d))'; } $sql = "SELECT * FROM {$table_name} WHERE {$where_clause} AND {$cursor_condition} ORDER BY {$orderby} {$order}, id {$order} LIMIT %d"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $cursor_values[] = $per_page; $results = $wpdb->get_results( $wpdb->prepare( $sql, $cursor_values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); return ( $results ? $results : array() ); } /** * Queries logs using offset-based pagination. * * @since 1.0.0 * * @param string $table_name Table name. * @param string $where_clause WHERE clause. * @param list $values Prepared values. * @param string $orderby Order by column. * @param string $order Sort order. * @param int $per_page Results per page. * @param int $page Current page number. * @return list> Query results. */ private function query_with_offset( string $table_name, string $where_clause, array $values, string $orderby, string $order, int $per_page, int $page ): array { global $wpdb; $offset = ( $page - 1 ) * $per_page; $sql = "SELECT * FROM {$table_name} WHERE {$where_clause} ORDER BY {$orderby} {$order}, id {$order} LIMIT %d OFFSET %d"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $values[] = $per_page; $values[] = $offset; $results = $wpdb->get_results( $wpdb->prepare( $sql, $values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); return ( $results ? $results : array() ); } /** * Gets the SQL date condition for a time period. * * @since 1.0.0 * * @param string $period Time period. * @return string SQL condition. */ private function get_date_condition( string $period ): string { switch ( $period ) { case 'minute': return 'AND timestamp >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 MINUTE)'; case 'hour': return 'AND timestamp >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 HOUR)'; case 'day': return 'AND timestamp >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)'; case 'week': return 'AND timestamp >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 WEEK)'; case 'month': return 'AND timestamp >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)'; default: return ''; } } /** * Aggregates summary rows into statistics. * * @since 1.0.0 * * @param list> $rows Raw query rows. * @return array{total_requests: int, total_tokens: int, avg_duration_ms: float, success_rate: float, by_type: array, by_provider: array, by_status: array} Aggregated statistics. */ private function aggregate_summary_rows( array $rows ): array { $total_requests = 0; $total_tokens = 0; $total_duration = 0.0; $success_count = 0; $by_type = array(); $by_provider = array(); $by_status = array(); foreach ( $rows as $row ) { $count = (int) $row['total_requests']; $total_requests += $count; $total_tokens += (int) $row['total_tokens']; $total_duration += (float) $row['avg_duration_ms'] * $count; $success_count += (int) $row['success_count']; if ( $row['type'] ) { $by_type[ $row['type'] ] = ( $by_type[ $row['type'] ] ?? 0 ) + $count; } if ( $row['provider'] ) { $by_provider[ $row['provider'] ] = ( $by_provider[ $row['provider'] ] ?? 0 ) + $count; } if ( ! $row['status'] ) { continue; } $by_status[ $row['status'] ] = ( $by_status[ $row['status'] ] ?? 0 ) + $count; } $avg_duration_ms = $total_requests > 0 ? $total_duration / $total_requests : 0; $success_rate = $total_requests > 0 ? $success_count / $total_requests * 100 : 0; return array( 'total_requests' => $total_requests, 'total_tokens' => $total_tokens, 'avg_duration_ms' => round( $avg_duration_ms, 2 ), 'success_rate' => round( $success_rate, 2 ), 'by_type' => $by_type, 'by_provider' => $by_provider, 'by_status' => $by_status, ); } /** * Aggregates filter option rows. * * @since 1.0.0 * * @param list> $rows Raw query rows. * @return array{types: list, providers: list, statuses: list, operations: list, user_ids: list} Filter options. */ private function aggregate_filter_options( array $rows ): array { $result = array( 'types' => array(), 'providers' => array(), 'statuses' => array(), 'operations' => array(), 'user_ids' => array(), ); foreach ( $rows as $row ) { switch ( $row['category'] ) { case 'type': $result['types'][] = $row['value']; break; case 'provider': $result['providers'][] = $row['value']; break; case 'status': $result['statuses'][] = $row['value']; break; case 'operation': $result['operations'][] = $row['value']; break; case 'user': $result['user_ids'][] = (int) $row['value']; break; } } sort( $result['types'] ); sort( $result['providers'] ); sort( $result['statuses'] ); sort( $result['operations'] ); sort( $result['user_ids'] ); return $result; } /** * Formats a raw database row into a structured log entry. * * @since 1.0.0 * * @param array $row Raw database row. * @return array Formatted log entry. */ private function format_log_row( array $row ): array { $duration = $row['duration_ms'] ? (int) $row['duration_ms'] : null; $tokens_total = $row['tokens_total'] ? (int) $row['tokens_total'] : null; $tokens_per_second = null; if ( null !== $tokens_total && $duration && $duration > 0 ) { $tokens_per_second = $tokens_total / ( $duration / 1000 ); } return array( 'id' => $row['log_id'], 'timestamp' => $row['timestamp'], 'type' => $row['type'], 'operation' => $row['operation'], 'provider' => $row['provider'], 'model' => $row['model'], 'duration_ms' => $duration, 'tokens_input' => $row['tokens_input'] ? (int) $row['tokens_input'] : null, 'tokens_output' => $row['tokens_output'] ? (int) $row['tokens_output'] : null, 'tokens_total' => $tokens_total, 'tokens_per_second' => null !== $tokens_per_second ? (float) $tokens_per_second : null, 'status' => $row['status'], 'error_message' => $row['error_message'], 'user_id' => $row['user_id'] ? (int) $row['user_id'] : null, 'context' => $row['context'] ? json_decode( $row['context'], true ) : null, ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching }