schema = $schema ?? new AI_Request_Log_Schema(); $this->repository = $repository ?? new AI_Request_Log_Repository( $this->schema ); } /** * Initializes the log manager. * * @since 1.0.0 */ public function init(): void { if ( $this->initialized ) { return; } $this->schema->maybe_upgrade_table(); add_action( self::CLEANUP_HOOK, array( $this, 'handle_cleanup_old_logs' ) ); // Only register the cleanup cron job if maximum retention is enabled. $retention_days = $this->get_retention_days(); $is_scheduled = (bool) wp_next_scheduled( self::CLEANUP_HOOK ); if ( $retention_days > 0 && ! $is_scheduled ) { wp_schedule_event( time(), 'daily', self::CLEANUP_HOOK ); } elseif ( 0 === $retention_days && $is_scheduled ) { wp_clear_scheduled_hook( self::CLEANUP_HOOK ); } $this->initialized = true; } /** * Gets the retention period in days. * * Logs are retained indefinitely by default. * * @since 1.0.0 * * @return int Number of days to retain logs, or 0 to retain forever. */ public function get_retention_days(): int { /** * Filters the retention period for AI request logs. * * Return a positive integer to enable time-based cleanup (e.g. 30 to * keep the last 30 days). Return 0 to retain logs indefinitely. * * @since 1.0.0 * * @param int $retention_days Number of days to retain logs (0 = forever). */ return (int) apply_filters( 'wpai_request_log_retention_days', 0 ); } /** * Starts a timer for measuring request duration. * * @since 1.0.0 * * @return array{start: int} Timer data. */ public function start_timer(): array { return array( 'start' => hrtime( true ), ); } /** * Ends a timer and returns duration in milliseconds. * * @since 1.0.0 * * @param array{start: int} $timer Timer data from start_timer(). * @return int Duration in milliseconds. */ public function end_timer( array $timer ): int { $end = hrtime( true ); return (int) ( ( $end - $timer['start'] ) / 1e6 ); } /** * 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 log( array $data ) { $type = $data['type'] ?? ''; $types = self::get_types(); // Ensure the data has a valid `type`. if ( ! in_array( $type, $types, true ) ) { _doing_it_wrong( __METHOD__, sprintf( /* translators: 1: the supplied log type, 2: comma-separated list of supported log types. */ esc_html__( 'Unsupported log type: %1$s. Supported types are: %2$s.', 'ai' ), esc_html( is_scalar( $type ) ? (string) $type : gettype( $type ) ), esc_html( implode( ', ', $types ) ) ), '1.3.0' ); return false; } // Ensure we have `operation` and `status`, since both are stored in columns that cannot be null. foreach ( array( 'operation', 'status' ) as $field ) { if ( ! isset( $data[ $field ] ) || ! is_string( $data[ $field ] ) || '' === $data[ $field ] ) { _doing_it_wrong( __METHOD__, sprintf( /* translators: %s: the name of the required log field. */ esc_html__( 'The %s log field is required and must be a non-empty string.', 'ai' ), esc_html( $field ) ), '1.3.0' ); return false; } } return $this->repository->insert( $data ); } /** * Gets the log types that may be recorded. * * `ai_client` covers generations made through the AI Client SDK. `mcp_tool` * and `ability` are for consumers that surface abilities themselves, such as * an MCP server or a direct ability invocation. * * @since 1.3.0 * * @return list Supported log types. */ public static function get_types(): array { return array( 'ai_client', 'mcp_tool', 'ability' ); } /** * 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 get_log( string $log_id ): ?array { return $this->repository->find( $log_id ); } /** * Retrieves logs with filtering and pagination. * * @since 1.0.0 * * @param array $args Query arguments. * @return array{items: list>, total: int, pages: int, next_cursor?: array{id: int, timestamp: string}} Results. */ public function get_logs( array $args = array() ): array { return $this->repository->query( $args ); } /** * 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 Aggregated statistics. */ public function get_summary( string $period = 'day', bool $force_refresh = false ): array { return $this->repository->get_summary( $period, $force_refresh ); } /** * 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, users: list} Filter options. */ public function get_filter_options( bool $force_refresh = false ): array { $options = $this->repository->get_filter_options( $force_refresh ); return $this->resolve_user_filter_options( $options ); } /** * Replaces the raw `user_ids` list with a `users` list of value/label pairs. * * Display names are looked up via WordPress; absent users fall back to a * "User #N" label so the chip stays informative if a user has been deleted. * * @since 1.0.0 * * @param array{types: list, providers: list, statuses: list, operations: list, user_ids: list} $options Filter options from the repository. * @return array{types: list, providers: list, statuses: list, operations: list, users: list} */ private function resolve_user_filter_options( array $options ): array { $user_ids = $options['user_ids'] ?? array(); unset( $options['user_ids'] ); $users = array(); foreach ( (array) $user_ids as $user_id ) { $user_id = (int) $user_id; if ( $user_id <= 0 ) { continue; } $user = get_user_by( 'id', $user_id ); $label = $user ? $user->display_name : sprintf( /* translators: %d: user ID. */ __( 'User #%d', 'ai' ), $user_id ); $users[] = array( 'value' => (string) $user_id, 'label' => $label, ); } $options['users'] = $users; return $options; } /** * Deletes logs older than the retention period. * * @since 1.0.0 * * @return int Number of logs deleted. */ public function cleanup_old_logs(): int { $total_deleted = $this->repository->cleanup_by_retention( $this->get_retention_days() ); if ( $total_deleted > 0 ) { $this->repository->invalidate_caches(); } return $total_deleted; } /** * Runs cleanup when invoked by the scheduled action. * * @since 1.0.0 */ public function handle_cleanup_old_logs(): void { $this->cleanup_old_logs(); } /** * Deletes logs older than the given number of days. * * @since 1.5.0 * * @param int $days Delete logs older than this many days, or 0 to delete all logs. * @return int Number of logs deleted. */ public function delete_logs_older_than( int $days ): int { if ( $days <= 0 ) { return $this->purge_all_logs(); } $total_deleted = $this->repository->cleanup_by_retention( $days ); if ( $total_deleted > 0 ) { $this->repository->invalidate_caches(); } return $total_deleted; } /** * Purges all logs from the database. * * @since 1.0.0 * * @return int Number of logs deleted. */ public function purge_all_logs(): int { return $this->repository->purge_all(); } /** * Invalidates the filter options cache. * * @since 1.0.0 */ public function invalidate_filter_cache(): void { $this->repository->invalidate_filter_cache(); } /** * Invalidates the summary cache. * * @since 1.0.0 */ public function invalidate_summary_cache(): void { $this->repository->invalidate_summary_cache(); } /** * Invalidates all caches. * * @since 1.0.0 */ public function invalidate_caches(): void { $this->repository->invalidate_caches(); } /** * Returns the schema manager for direct access if needed. * * @since 1.0.0 * * @return \WordPress\AI\Logging\AI_Request_Log_Schema The schema manager. */ public function get_schema(): AI_Request_Log_Schema { return $this->schema; } /** * Returns the repository for direct access if needed. * * @since 1.0.0 * * @return \WordPress\AI\Logging\AI_Request_Log_Repository The repository. */ public function get_repository(): AI_Request_Log_Repository { return $this->repository; } }