326 lines
11 KiB
PHP
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());
|
|
}
|
|
}
|
|
}
|