API Logs — a record of outgoing integration requests * (Webhook, Slack, Discord, Telegram, Notion, Mailchimp, HubSpot, Google * Sheets — every integration that makes a real outbound HTTP call) and * their responses, for debugging a failed integration without needing * server error-log access. */ class Api_Log { public static function table() { global $wpdb; return $wpdb->prefix . 'chargeforms_api_log'; } /** Only 1 in every PRUNE_FREQUENCY inserts triggers a prune check — see prune()'s doc comment for why running it on every single insert was wasteful. */ const PRUNE_FREQUENCY = 50; public static function log( $integration, $form_id, $request_url, $response_code, $response_body ) { global $wpdb; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom plugin table, no core API exists for it. self::table(), [ 'integration' => $integration, 'form_id' => $form_id ?: null, 'request_url' => $request_url ? substr( $request_url, 0, 500 ) : null, 'response_code' => $response_code, 'response_excerpt' => $response_body ? substr( $response_body, 0, 500 ) : null, 'created_at' => current_time( 'mysql', true ), // GMT (roadmap issue #27) — see Activity_Log::log()'s identical note. ], [ '%s', '%d', '%s', '%d', '%s', '%s' ] ); // roadmap issue #34 — pruning itself runs a full COUNT(*) over the // table, which is unnecessary work to repeat on literally every // insert on an integration-heavy site. The insert id is a free, // already-monotonic counter to gate on — no extra option/state // needed to track "how long since we last checked." if ( 0 === $wpdb->insert_id % self::PRUNE_FREQUENCY ) { self::prune(); } } /** Keeps the table from growing unbounded on a busy site — only the most recent 500 calls are useful for debugging. Checked roughly every PRUNE_FREQUENCY inserts, not every single one (roadmap issue #34). */ private static function prune() { global $wpdb; $table = self::table(); $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. if ( $total <= 500 ) { return; } $wpdb->query( $wpdb->prepare( 'DELETE FROM %i ORDER BY created_at ASC LIMIT %d', $table, $total - 500 ) ); } /** * Daily success/failed counts (the reference product parity — its Reports screen * has an "API Logs" success/processing/failed time series ChargeForms's * own Reports didn't have). Modeled directly on * Entry::counts_by_day_and_status() — same shape, filled-zero-days * approach, GROUP BY on a derived column instead of a stored one, since * this table has no `status` enum column: success/failure is derived * from `response_code` (2xx = success; NULL or >=300 = failed) the same * way every integration class already decides it when logging. * * Caveat inherited from prune() above: only the most recent 500 rows * total exist at any time, across every integration combined, so a * busy site's true daily counts for the earlier days of a wide window * can undercount — this reads whatever survived pruning, not a * guaranteed-complete history. * * @return array{total: array, success: array, failed: array} keyed by Y-m-d, oldest first. */ public static function counts_by_day_and_result( $days = 14, $integration = null ) { global $wpdb; $since = gmdate( 'Y-m-d 00:00:00', strtotime( '-' . ( $days - 1 ) . ' days' ) ); $table = self::table(); $where = 'created_at >= %s'; $params = [ $table, $since ]; if ( $integration ) { $where .= ' AND integration = %s'; $params[] = $integration; } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT DATE(created_at) as day, (response_code >= 200 AND response_code < 300) as is_success, COUNT(*) as total FROM %i WHERE {$where} GROUP BY DATE(created_at), is_success", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $where's placeholder count always matches $params' length at runtime; the sniff can't trace that through the interpolation. $params ), ARRAY_A ); $by_day = []; foreach ( $rows ?: [] as $row ) { $key = $row['is_success'] ? 'success' : 'failed'; $by_day[ $row['day'] ][ $key ] = (int) $row['total']; } $series = [ 'total' => [], 'success' => [], 'failed' => [] ]; for ( $i = $days - 1; $i >= 0; $i-- ) { $day = gmdate( 'Y-m-d', strtotime( "-{$i} days" ) ); $success = $by_day[ $day ]['success'] ?? 0; $failed = $by_day[ $day ]['failed'] ?? 0; $series['success'][ $day ] = $success; $series['failed'][ $day ] = $failed; $series['total'][ $day ] = $success + $failed; } return $series; } public static function query( array $args = [] ) { global $wpdb; $page = max( 1, (int) ( $args['page'] ?? 1 ) ); $per_page = max( 1, (int) ( $args['per_page'] ?? 20 ) ); $offset = ( $page - 1 ) * $per_page; $table = self::table(); $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. $wpdb->prepare( 'SELECT * FROM %i ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d', $table, $per_page, $offset ), ARRAY_A ); return [ 'items' => $rows ?: [], 'total' => $total, 'page' => $page, 'per_page' => $per_page, ]; } }