|null $tool Tool spec. * @param Identity $identity Caller. * @param string $status success | error | denied. * @param mixed $result Result. * @param array $args Arguments. * @param float $start microtime at start. */ public function record( string $name, ?array $tool, Identity $identity, string $status, $result, array $args, float $start ): void { global $wpdb; $is_write = $tool ? (bool) $tool['write'] : false; $summary = ''; if ( is_array( $result ) && isset( $result['error']['message'] ) ) { $summary = (string) $result['error']['message']; } elseif ( is_array( $result ) && isset( $result['message'] ) && is_string( $result['message'] ) ) { $summary = $result['message']; } $ref = ''; foreach ( array( 'post_id', 'id', 'product_id', 'order_id', 'form_id', 'template_id', 'element_id' ) as $key ) { if ( isset( $args[ $key ] ) && is_scalar( $args[ $key ] ) ) { $ref = $key . ':' . $args[ $key ]; break; } } if ( '' === $ref && is_array( $result ) && isset( $result['id'] ) && is_scalar( $result['id'] ) ) { $ref = 'id:' . $result['id']; } $stored_args = ''; if ( $is_write ) { $stored_args = (string) wp_json_encode( self::shorten( Redactor::redact( $args ) ) ); if ( strlen( $stored_args ) > 4000 ) { $stored_args = substr( $stored_args, 0, 4000 ) . '...'; } } try { $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery Installer::table( 'log' ), array( 'created_at' => gmdate( 'Y-m-d H:i:s' ), 'user_id' => $identity->user_id, 'client' => mb_substr( (string) $identity->client, 0, 190 ), 'auth_method' => $identity->method, 'tool' => mb_substr( $name, 0, 120 ), 'tool_group' => $tool ? (string) $tool['group'] : '', 'is_write' => $is_write ? 1 : 0, 'status' => $status, 'object_ref' => mb_substr( $ref, 0, 190 ), 'summary' => mb_substr( Redactor::scrub_string( $summary ), 0, 500 ), 'args' => $stored_args, 'duration_ms' => (int) round( ( microtime( true ) - $start ) * 1000 ), ) ); } catch ( \Throwable $e ) { return; // Logging never breaks a tool call. } } /** * Truncate long strings and big arrays so the log stays small. * * @param mixed $value Value. * @param int $depth Depth. * @return mixed */ private static function shorten( $value, int $depth = 0 ) { if ( is_string( $value ) ) { return mb_strlen( $value ) > 300 ? mb_substr( $value, 0, 300 ) . '...(' . mb_strlen( $value ) . ' chars)' : $value; } if ( is_array( $value ) ) { if ( $depth > 3 ) { return '[' . count( $value ) . ' items]'; } $out = array(); $i = 0; foreach ( $value as $k => $v ) { if ( ++$i > 25 ) { $out['...'] = ( count( $value ) - 25 ) . ' more'; break; } $out[ $k ] = self::shorten( $v, $depth + 1 ); } return $out; } return $value; } /** * Recent entries. * * @param array $filter status, tool, limit, offset. * @return array{rows: array, total: int} */ public function query( array $filter = array() ): array { global $wpdb; $t = Installer::table( 'log' ); $where = array( '1=1' ); $params = array(); if ( ! empty( $filter['status'] ) ) { $where[] = 'status = %s'; $params[] = (string) $filter['status']; } if ( ! empty( $filter['writes'] ) ) { $where[] = 'is_write = 1'; } $limit = max( 1, min( 200, (int) ( $filter['limit'] ?? 50 ) ) ); $offset = max( 0, (int) ( $filter['offset'] ?? 0 ) ); $cond = implode( ' AND ', $where ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- custom table; $cond only joins fixed condition strings, values go through placeholders. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE {$cond} ORDER BY id DESC LIMIT %d OFFSET %d", array_merge( array( $t ), $params, array( $limit, $offset ) ) ) ); $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$cond}", array_merge( array( $t ), $params ) ) ); // phpcs:enable return array( 'rows' => is_array( $rows ) ? $rows : array(), 'total' => $total, ); } /** * Counts for the last N days. * * @param int $days Days. * @return array */ public function stats( int $days = 7 ): array { global $wpdb; $t = Installer::table( 'log' ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT status, is_write, COUNT(*) AS n FROM %i WHERE created_at > %s GROUP BY status, is_write', $t, gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, stats must be current. $out = array( 'calls' => 0, 'writes' => 0, 'errors' => 0 ); foreach ( (array) $rows as $r ) { $out['calls'] += (int) $r->n; if ( (int) $r->is_write && 'success' === $r->status ) { $out['writes'] += (int) $r->n; } if ( 'error' === $r->status ) { $out['errors'] += (int) $r->n; } } return $out; } }