Cada petición que llega a una aplicación de API de esta flota pasa por LogApiRequestsMiddleware, y todas llevan los mismos cuatro números que merece la pena poner en un panel: cuántas, cuán rápidas, con qué frecuencia fallan y cuán cerca del límite van. Ten esos cuatro delante y podrás responder a «¿está sana la API?» sin leer una sola traza.
AddRateLimitHeadersMiddleware es la ilustración más clara de la cuarta —la saturación— porque convierte un contador interno en algo visible en cada respuesta.
// api-infrastructure/src/Middleware/AddRateLimitHeadersMiddleware.php
final readonly class AddRateLimitHeadersMiddleware
{
public function __construct(
private RateLimiter $limiter,
) {}
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$token = $request->bearerToken() ?? $request->ip();
if (! is_string($token)) {
$token = 'unknown';
}
$key = 'api-token:'.$token;
$maxAttempts = (int) config('webplo.rate_limit');
$remainingAttempts = $this->limiter->remaining($key, $maxAttempts);
$response->headers->set('X-RateLimit-Limit', (string) $maxAttempts);
$response->headers->set('X-RateLimit-Remaining', (string) max(0, $remainingAttempts));
return $response;
}
}
La imposición ya ocurrió en throttle:api-token antes de que este middleware corriera; este sólo informa del número. Las otras tres señales funcionan igual, visibles sin abrir un depurador:
- Volumen: peticiones que aterrizan, desglosadas por endpoint y código de estado. Una caída a cero es tan alarmante como un pico; normalmente significa que un cliente dejó de llamarte, no que todo el mundo dejó de necesitarte.
- Latencia:
execution_time, medida enLogApiRequestsMiddlewarealrededor de$next($request). Sigue el percentil 95, no la media. La media esconde ese único endpoint lento que arrastra el panel de un comerciante. - Errores: el código de estado de cada petición registrada. Un
422por una carga rechazada no es la misma señal que un500por una excepción no controlada; un panel que los trata igual te entrena para ignorar ambos. - Saturación:
X-RateLimit-Remaining, en tendencia sobre toda tu base de llamantes, no sobre un token. Si ese número deriva hacia cero en todas partes, estás a punto de ver una ola de429diga lo que diga tu gráfica de latencia.
Un endpoint te dice que va lento. Otro te dice que está fallando. Un tercero te dice que está a punto de ser limitado. Ninguno por sí solo te dice que la API está sana. Los cuatro juntos, sí.
Alertar sobre sintomas, no sobre causas
La verdad incómoda: la mayoría de las alertas se disparan sobre causas y no sobre síntomas, y por eso quien está de guardia aprende a ignorarlas. Un disco casi lleno es una causa. El sitio de un comerciante devolviendo 500 es un síntoma. Avisa por lo segundo. Registra lo primero y léelo cuando no te estén despertando.
// Bad: every exception gets the same alert weight
$exceptions->report(function (Throwable $exception) {
Nightwatch::captureException($exception);
});
En esa versión toda excepción avisa igual, sea un cliente enviando JSON malformado o un fallo genuino no controlado. El equipo que alerta de forma uniforme acaba con un canal que todo el mundo silencia en un mes. Esto es lo que se hace en su lugar:
// api-license/bootstrap/app.php
$exceptions->report(function (Throwable $exception) {
try {
if ($exception instanceof ThrottleRequestsException) {
Nightwatch::warning('Rate limit exceeded', [
'exception' => class_basename($exception),
]);
} elseif ($exception instanceof ValidationException) {
Nightwatch::info('Validation failed', [
'errors' => $exception->errors(),
]);
} elseif ($exception instanceof ModelNotFoundException) {
Nightwatch::warning('Resource not found', [
'exception' => class_basename($exception),
]);
} else {
Nightwatch::captureException($exception);
}
} catch (Throwable) {
// Nightwatch not available or container not initialized
}
});
ThrottleRequestsException: un cliente alcanzando su límite es comportamiento esperado bajo carga, no un fallo del sistema. Se registra como aviso para que un pico sea visible en agregado, pero nadie recibe una llamada porque un llamante se calentó.ValidationException: un cliente enviando datos malos es su fallo, no el tuyo.ModelNotFoundException: merece un aviso, ya que una ola de estos puede significar que alguien está sondeando recursos que no existen. Uno solo es sólo un enlace caducado.- Todo lo demás:
captureExceptiones la única rama que llega a tu rastreador con una traza completa, porque por construcción todo lo que llega ahí es algo a lo que nadie le había puesto nombre todavía.
La misma disciplina aparece una capa más abajo, dentro de los jobs. LicenseCreateJob reintenta una activación fallida a lo largo de cinco intentos repartidos en unas seis horas antes de rendirse.
// api-server/app/Jobs/LicenseCreateJob.php
} catch (Throwable $e) {
// Only surface the exception (and report to Nightwatch) on the
// final attempt. Otherwise release silently using the configured
// backoff so transient upstream failures don't generate noise.
if ($this->attempts() < $this->tries) {
$this->release($this->nextBackoffSeconds());
return;
}
throw $e;
}
Los cuatro primeros fallos son la causa: un servicio externo estuvo brevemente no disponible, algo esperado y silencioso. El quinto es el síntoma: una licencia que sigue sin estar activa tras todos los intentos razonables, y sólo ese lanza lo bastante lejos como para llegar al rastreador. Dentro de seis meses, quien esté de guardia te agradecerá la diferencia.