El contexto que hace que un log merezca guardarse
Una línea de log que dice "error" => $e->getMessage() apenas es un log. Te dice que algo falló, no por qué, ni para quién, ni qué hacer al respecto.
Esta es una excepción de un servicio hermano que compra nombres de dominio:
class DomainPurchaseFailed extends RuntimeException
{
public function __construct(
string $message,
public readonly bool $outcomeUnknown = false,
) {
parent::__construct($message);
}
/** @return array<string, mixed> */
public function context(): array
{
return ['outcome_unknown' => $this->outcomeUnknown];
}
}
$outcomeUnknown es el campo que gobierna dinero real. false significa que el registrador con toda seguridad no hizo nada, y es seguro reembolsar. true significa que el registrador quizá cobró el dominio de todos modos, y reembolsar o reintentar a ciegas podría cobrar dos veces o registrar dos veces. Si solo registraras el mensaje, esa distinción se perdería en cuanto la traza de pila desaparece de la pantalla.
El método context() es de Laravel: lo que devuelva se adjunta a la entrada de log cuando la excepción se reporta. La excepción lleva el contexto porque el mensaje solo no puede. (Documentación de Laravel: Error Handling › Exception Log Context.)
Una petición, muchas líneas de log
Una sola llamada a esta API produce un log de petición, quizá una excepción, un job encolado y una llamada al proveedor. Cuando algo falla los necesitas todos, juntos.
La facade Context de Laravel está hecha para esto. Añade un valor una vez, al comienzo de la petición, y queda adjunto a cada entrada de log que se escriba después, incluidas las que escriban más tarde los jobs encolados que la petición despachó:
// app/Http/Middleware/AssignTraceId.php
public function handle(
Request $request,
Closure $next,
): Response {
$incoming = (string) $request->header('X-Trace-Id');
$traceId = Str::isUuid($incoming)
? $incoming
: (string) Str::uuid();
Context::add('trace_id', $traceId);
$response = $next($request);
$response->headers->set('X-Trace-Id', $traceId);
return $response;
}
Va el primero en el grupo api, por delante del SetLocale del Capítulo 6, con api(prepend: [AssignTraceId::class, SetLocale::class]), para que hasta un 401 o un 429 lleven un ID.
Aceptar un X-Trace-Id entrante significa que un consumidor que ya tiene uno lo conserva, y el mismo ID sigue a la operación a través de los servicios. Solo se acepta si es un UUID. Una cabecera es una entrada como cualquier otra, y esta está a punto de escribirse en cada línea de log. Devolverlo en la respuesta significa que un consumidor puede citarlo cuando reporte un problema. Envíaselo también al proveedor, con withHeader() en el cliente HTTP, y podrás cotejar tu log con el suyo. (Documentación de Laravel: Context.)
Operé un sistema de varios servicios durante mucho tiempo sin esto, cruzando logs por la identidad de quien llamaba y una ventana de tiempo estrecha. Funciona hasta que dos operaciones del mismo llamante caen en el mismo segundo. Un ID de traza cuesta un middleware pequeño.
Cuando lo que se rompió es el logger
Todo en este capítulo depende de que algo esté en pie: el rastreador de errores, la cola, el almacén de logs. Ninguno está garantizado.
No puedes hacer que el logging sea infalible. Sí puedes asegurarte de que un fallo de logging no arrastre consigo a la petición. El Capítulo 6 ya lo hizo para el log de peticiones: se escribe en terminate(), después de que la respuesta ha salido, así que un almacén de logs lento degrada tu logging y no tu API.
La regla de fondo es que lo que observa una petición debe poder fallar sin que la petición falle. Si capturar lo que salió mal puede tumbar lo que ya está mal, has construido el modo de fallo que intentabas atrapar.
Resumen del capítulo 7
- Los errores pasan por una sola puerta:
withExceptions()enbootstrap/app.php. Los controladores no atrapan excepciones. - Conserva la forma de error de Laravel. Añádele cosas solo donde Laravel no puede saber lo que quieres decir, y dale a todo 404 el mismo cuerpo.
- Un fallo en un proveedor es un 502 o un 504, no un 500, y la respuesta nunca repite el mensaje del proveedor.
- Un código de estado dice qué clase de cosa pasó. Los fallos de dominio llevan un
codeestable junto al mensaje traducido, para decir cuál. - Dale forma al reporte con
level(),dontReport()yShouldntReport. No llames tú al rastreador de errores. - Pon contexto en la excepción, y un ID de traza en
Context, para que un fallo pueda leerse como una sola historia. - El logging debe poder fallar sin hacer fallar la petición.