Files
whatsapp/services/RateLimitMonitor.php
2026-02-20 09:07:37 -05:00

326 lines
11 KiB
PHP

<?php
/**
* Servicio de Monitoreo de Rate Limiting de Facebook
* Captura y analiza headers X-App-Usage y X-Business-Use-Case-Usage
* Fecha: 6 de febrero de 2026
*/
use Predis\Client as RedisClient;
class RateLimitMonitor {
private $redis;
private $enabled;
// Umbrales de alerta
const WARNING_THRESHOLD = 75; // Advertencia al 75%
const CRITICAL_THRESHOLD = 90; // Crítico al 90%
public function __construct() {
try {
// Usar el mismo patrón que RedisQueue
$this->redis = new RedisClient([
'scheme' => getenv('REDIS_SCHEME') ?: 'tcp',
'host' => getenv('REDIS_HOST') ?: '127.0.0.1',
'port' => getenv('REDIS_PORT') ?: 6379,
'password' => getenv('REDIS_PASSWORD') ?: null,
'database' => getenv('REDIS_DB') ?: 0,
]);
// Verificar conexión
$this->redis->ping();
$this->enabled = true;
} catch (Exception $e) {
error_log("RateLimitMonitor: Redis no disponible - " . $e->getMessage());
$this->enabled = false;
}
}
/**
* Captura y almacena información de rate limit desde headers HTTP
* @param array $responseHeaders - Headers de la respuesta de Facebook API
* @param string $endpoint - Endpoint llamado (para tracking)
*/
public function captureFromHeaders(array $responseHeaders, string $endpoint = 'unknown'): void {
if (!$this->enabled) return;
try {
$headerText = is_array($responseHeaders) ? implode("\n", $responseHeaders) : $responseHeaders;
// Buscar X-App-Usage
if (preg_match('/x-app-usage:\s*({[^}]+})/i', $headerText, $matches)) {
$usage = json_decode($matches[1], true);
if ($usage) {
$this->recordAppUsage($usage, $endpoint);
}
}
// Buscar X-Business-Use-Case-Usage
if (preg_match('/x-business-use-case-usage:\s*({.+})/i', $headerText, $matches)) {
$usage = json_decode($matches[1], true);
if ($usage) {
$this->recordBusinessUsage($usage, $endpoint);
}
}
} catch (Exception $e) {
error_log("RateLimitMonitor: Error capturando headers - " . $e->getMessage());
}
}
/**
* Registra uso a nivel de App (X-App-Usage)
*/
private function recordAppUsage(array $usage, string $endpoint): void {
$timestamp = time();
$key = "fb:ratelimit:app:current";
$historyKey = "fb:ratelimit:app:history";
$data = [
'call_count' => $usage['call_count'] ?? 0,
'total_time' => $usage['total_time'] ?? 0,
'total_cputime' => $usage['total_cputime'] ?? 0,
'endpoint' => $endpoint,
'timestamp' => $timestamp,
'datetime' => date('Y-m-d H:i:s')
];
// Guardar estado actual
$this->redis->setex($key, 3600, json_encode($data));
// Agregar a historial (últimas 100 muestras)
$this->redis->lpush($historyKey, json_encode($data));
$this->redis->ltrim($historyKey, 0, 99);
// Verificar umbrales y generar alertas
$this->checkThresholds('app', $data);
}
/**
* Registra uso a nivel de Business (X-Business-Use-Case-Usage)
*/
private function recordBusinessUsage(array $usage, string $endpoint): void {
$timestamp = time();
foreach ($usage as $businessId => $metrics) {
if (!is_array($metrics)) continue;
foreach ($metrics as $metric) {
if (!is_array($metric)) continue;
$type = $metric['type'] ?? 'unknown';
$key = "fb:ratelimit:business:{$businessId}:{$type}";
$historyKey = "fb:ratelimit:business:{$businessId}:{$type}:history";
$data = [
'business_id' => $businessId,
'type' => $type,
'call_count' => $metric['call_count'] ?? 0,
'total_time' => $metric['total_time'] ?? 0,
'total_cputime' => $metric['total_cputime'] ?? 0,
'estimated_time_to_regain_access' => $metric['estimated_time_to_regain_access'] ?? 0,
'ads_api_access_tier' => $metric['ads_api_access_tier'] ?? null,
'endpoint' => $endpoint,
'timestamp' => $timestamp,
'datetime' => date('Y-m-d H:i:s')
];
// Guardar estado actual
$this->redis->setex($key, 3600, json_encode($data));
// Agregar a historial
$this->redis->lpush($historyKey, json_encode($data));
$this->redis->ltrim($historyKey, 0, 99);
// Verificar umbrales
$this->checkThresholds('business', $data);
}
}
}
/**
* Verifica umbrales y genera alertas si es necesario
*/
private function checkThresholds(string $level, array $data): void {
$callCount = $data['call_count'] ?? 0;
$totalTime = $data['total_time'] ?? 0;
$totalCputime = $data['total_cputime'] ?? 0;
$maxUsage = max($callCount, $totalTime, $totalCputime);
if ($maxUsage >= self::CRITICAL_THRESHOLD) {
$this->triggerAlert('critical', $level, $data, $maxUsage);
} elseif ($maxUsage >= self::WARNING_THRESHOLD) {
$this->triggerAlert('warning', $level, $data, $maxUsage);
}
}
/**
* Dispara una alerta de rate limit
*/
private function triggerAlert(string $severity, string $level, array $data, float $usage): void {
$alertKey = "fb:ratelimit:alerts";
$alert = [
'severity' => $severity,
'level' => $level,
'usage' => $usage,
'data' => $data,
'timestamp' => time(),
'datetime' => date('Y-m-d H:i:s')
];
// Guardar alerta
$this->redis->lpush($alertKey, json_encode($alert));
$this->redis->ltrim($alertKey, 0, 49); // Mantener últimas 50 alertas
$this->redis->expire($alertKey, 86400 * 7); // 7 días
// Log
error_log("R
'error' => 'Redis no disponible',
'app_usage' => null,
'business_usage' => [],
'local_limits' => $this->getLocalLimits(),
'alerts' => [],
'status' => 'unavailable'
rt - {$level} usage at {$usage}% for endpoint: " . ($data['endpoint'] ?? 'unknown'));
}
/**
* Obtiene estadísticas actuales de rate limit
*/
public function getCurrentStats(): array {
if (!$this->enabled) {
return ['error' => 'Redis no disponible'];
}
try {
$stats = [
'app_usage' => null,
'business_usage' => [],
'local_limits' => $this->getLocalLimits(),
'alerts' => [],
'status' => 'healthy'
];
// Obtener uso a nivel de app
$appData = $this->redis->get("fb:ratelimit:app:current");
if ($appData) {
$stats['app_usage'] = json_decode($appData, true);
}
// Obtener uso a nivel de business
$businessKeys = $this->redis->keys("fb:ratelimit:business:*:current") ?: [];
foreach ($businessKeys as $key) {
$data = $this->redis->get($key);
if ($data) {
$decoded = json_decode($data, true);
$stats['business_usage'][] = $decoded;
}
}
// Obtener alertas recientes
$alerts = $this->redis->lrange("fb:ratelimit:alerts", 0, 9);
foreach ($alerts as $alert) {
$stats['alerts'][] = json_decode($alert, true);
}
// Determinar estado general
$maxUsage = 0;
if ($stats['app_usage']) {
$maxUsage = max(
$stats['app_usage']['call_count'] ?? 0,
$stats['app_usage']['total_time'] ?? 0,
$stats['app_usage']['total_cputime'] ?? 0
);
}
if ($maxUsage >= self::CRITICAL_THRESHOLD) {
$stats['status'] = 'critical';
} elseif ($maxUsage >= self::WARNING_THRESHOLD) {
$stats['status'] = 'warning';
}
return $stats;
} catch (Exception $e) {
error_log("RateLimitMonitor: Error obteniendo stats - " . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* Obtiene límites locales (del sistema)
*/
private function getLocalLimits(): array {
try {
// Intentar obtener de WhatsAppServiceWithRateLimit si existe
if (class_exists('WhatsAppServiceWithRateLimit')) {
$reflection = new ReflectionClass('WhatsAppServiceWithRateLimit');
return [
'per_second' => $reflection->getConstant('RATE_LIMIT_PER_SECOND') ?: 80,
'per_hour' => $reflection->getConstant('RATE_LIMIT_PER_HOUR') ?: 1000,
'per_day' => $reflection->getConstant('RATE_LIMIT_PER_DAY') ?: 10000,
];
}
} catch (Exception $e) {
// Fallback a valores por defecto
}
return [
'per_second' => 80,
'per_hour' => 1000,
'per_day' => 10000,
];
}
/**
* Obtiene historial de uso
*/
public function getUsageHistory(string $type = 'app', int $limit = 20): array {
if (!$this->enabled) {
return [];
}
try {
$key = "fb:ratelimit:{$type}:history";
$items = $this->redis->lrange($key, 0, $limit - 1);
$history = [];
foreach ($items as $item) {
$decoded = json_decode($item, true);
if ($decoded) {
$history[] = $decoded;
}
}
return $history;
} catch (Exception $e) {
error_log("RateLimitMonitor: Error obteniendo historial - " . $e->getMessage());
return [];
}
}
/**
* Limpia datos antiguos (mantenimiento)
*/
public function cleanup(): void {
if (!$this->enabled) return;
try {
// Limpiar alertas antiguas
$this->redis->expire("fb:ratelimit:alerts", 86400 * 7);
// Limpiar historiales antiguos
$historyKeys = $this->redis->keys("fb:ratelimit:*:history") ?: [];
foreach ($historyKeys as $key) {
$this->redis->ltrim($key, 0, 99);
}
} catch (Exception $e) {
error_log("RateLimitMonitor: Error en cleanup - " . $e->getMessage());
}
}
}