La vida de un log de notificación
Cada envío deja una fila en NotificationLog. Su estado lo escriben tres actores distintos, y
saber cuál puso cada cosa es lo que permite leer un incidente.
worker worker webhook de notification-api
PENDING ──────────────→ SENT / FAILED ──────→ DELIVERED / BOUNCED / SPAM
(antes de enviar) (tras el envío) (minutos después, solo email)
PENDING se escribe antes de enviar, y no es cosmético
NotificationLogTracker.ensurePending() crea las filas antes de llamar al proveedor, y devuelve
un booleano que decide si hay que enviar siquiera:
Las filas se escriben como
PENDINGantes de la llamada al proveedor para que un reintento —provocado por un fallo posterior al envío, por ejemplo un tropiezo de la base de datos— pueda distinguir un trabajo nuevo de uno que ya se despachó, en vez de reenviar.
Es decir: es la protección contra el envío duplicado, y opera a un nivel distinto del
idempotencyKey. Aquel evita encolar dos veces; este evita reenviar dentro de los tres intentos del
mismo trabajo.
La lógica: si ya existen filas para ese jobId y ninguna sigue en PENDING, el trabajo ya se
despachó y el processor vuelve temprano sin enviar.
Una fila por destinatario
Un push va a varios tokens, así que un trabajo escribe varias filas. El email es el caso degenerado
de “un destinatario”, como dice el comentario del tracker. Por eso las escrituras localizan la fila
siempre por (jobId, recipient).
En push, cada token se resuelve por separado dentro del mismo trabajo: los que FCM rechazó van a
FAILED con su errorCode en meta, y el resto a SENT.
Si el trabajo revienta antes de llegar al proveedor
Ahí no hay quien marque nada, y la fila se quedaría en PENDING para siempre. Lo cubre el handler
de fallo, solo en el último intento:
if (!job || !NotificationLogTracker.isFinalAttempt(job, error)) return;
await this.tracker.markFailedIfPending(job.id!, error.message);
Lo de “solo en el último intento” importa: marcar FAILED en el primero borraría el rastro de que
aún quedaban reintentos por delante.
Qué guarda meta
meta es una columna JSON que solo escribe el worker. Sus claves:
| Clave | Cuándo se escribe | Qué es |
|---|---|---|
platform |
Push, con la fila PENDING |
Plataforma del dispositivo (ios, android o web), si el productor la mandó en devices |
templateTenantId |
Ambos canales, con la fila PENDING |
Tenant cuya plantilla se usó, solo cuando no es el tenant de entrega (ver tenantId contra sourceTenantId) |
errorCode |
Push fallido, al cerrar el trabajo | Código de error de FCM por token, o unknown si FCM no lo dio |
errorMessage |
Fallo final del trabajo | El error.message del último intento |
meta se fusiona, no se sobrescribe: una escritura posterior no puede perder lo que dejó la fila
PENDING. markFailedIfPending lo hace en SQL crudo
(COALESCE("meta", '{}'::jsonb) || jsonb_build_object('errorMessage', ...)), porque el update de
Prisma solo puede reemplazar una columna Json entera. Los push fallidos sí pasan por Prisma, así que
markPushResults repite platform y templateTenantId junto al errorCode. Una clave nueva que se
escriba con el PENDING tiene que repetirse ahí.
DELIVERED, BOUNCED y SPAM llegan después, y solo por email
Esos tres no los pone el worker: los pone Resend llamando al webhook de notification-api, que
verifica la firma con svix y mapea el evento:
| Evento de Resend | Estado |
|---|---|
email.delivered |
DELIVERED |
email.bounced |
BOUNCED |
email.complained |
SPAM |
La fila se localiza por providerMessageId, que es lo que el worker guardó al enviar. Cualquier
otro tipo de evento se ignora sin error.
En push no existe nada de esto. FCM no tiene webhook de entrega, y por eso DeliveryResult
documenta que providerMessageId es solo de email: un id de push no añadiría nada a status. El
estado final de un push es el que dejó el worker.
Si el webhook no encuentra fila, lo dice en un warn y no falla — normalmente significa que el
providerMessageId no se llegó a guardar.
Quién las borra
NotificationLogRetentionTask, en el worker, cada día a las 4:00. Purga en lotes de 5.000 para
no sostener una transacción larga —la primera ejecución se lleva todo el atraso acumulado— y con
NOTIFICATION_LOG_RETENTION_DAYS=0 queda desactivada.
Detalle que agradecerás: al arrancar dice en el log si la purga está armada o no, precisamente porque un cron desactivado es indistinguible de uno que corre y no encuentra nada.