ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todo el contenido

La vida de un log de notificación

notification-api
último cambio 2026-09-25

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 PENDING antes 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.