BackDebugging and testing

Delivery timeline

Follow one notification from payload validation to the provider's final answer.

Reading It

Every send that creates a notification record gets a timeline: the events the engine recorded while handling it, in order. Where explain() predicts, the timeline tells you what actually happened.

const result = await notify.send({ /* ... */ })
const events = await notify.timeline(result.notification!.id)

// Only one delivery's events:
await notify.timeline(recordId, { deliveryId })
[
	{
		event: "payload.validated",
		message: "Payload validated successfully",
	},
	{
		event: "recipient.resolved",
		message: 'Recipient "user_123" resolved',
	},
	{
		event: "preferences.resolved",
		message: "Preference resolution complete: inbox, email enabled",
	},
	{
		event: "inbox.created",
		message: 'Inbox item created: "Rey mentioned you"',
	},
	{
		event: "delivery.created",
		message: "Email delivery created via resend",
	},
	{
		event: "delivery.sent",
		message: "Delivery sent on attempt 1",
	},
	{
		event: "provider.message_id_stored",
		message: "Provider message ID: 4f1c...",
	},
]

Results come back oldest first, up to 1,000 by default (pass limit to change that). Each event has these fields:

FieldContents
eventThe event type, from the table below
messageA readable description
channel, provider, deliveryIdSet on events about one delivery
metadataExtra detail, such as the attempt number, the error, or a skip reason
timestamp, seqWhen it was written; seq orders events written together
recipientId, tenantId, workspaceId, notificationIdWhich send it belongs to

Events

Stopped early

A send stopped before delivery still gets a notification record, with a single event (or two, for suppression) explaining why:

EventMeaning
idempotent.replayThe idempotency key was already used; the earlier result was returned
deduplicatedThe dedupe key was seen inside its window. metadata has the key and window
preferences.resolved + notification.suppressedEvery channel was turned off or had no address
rate_limitedOver the limit. metadata has max, windowMs, and the scope

Normal path

EventMeaning
payload.validatedThe payload passed
recipient.resolvedThe recipient was found. metadata says whether they have an email and a phone
preferences.resolvedLists the channels preferences allow
channel.skippedA channel was left out. metadata.reason is, for example, preferences_disabled or missing_address
quiet_hours.deferredA channel is scheduled for when quiet hours end
inbox.createdThe inbox item was written
delivery.createdAn email, SMS, or webhook delivery was queued

Delivery

EventMeaning
delivery.attemptA retry is starting. Written before attempt 2 onward, with the attempt number and delay
provider.errorAn attempt failed. metadata has the error and whether it was permanent
delivery.sentThe provider accepted it
provider.message_id_storedThe provider's message id, to look up in its dashboard
delivery.failedNo attempts left, or a permanent error
fallback.triggeredA fallback channel is firing, and why

Triage

The last event usually tells you what to do:

Last eventWhat happenedNext
delivery.sentNotifyKit handed it overCheck spam, and the provider's bounce log
delivery.failedThe provider refused it every timeRead the provider.error events before it
channel.skippedThe channel never ranpreferences_disabled is the user’s choice; missing_address means no email or phone
quiet_hours.deferredWaiting for quiet hours to endSee below if it never went out
deduplicated, rate_limitedStopped on purposeCheck that the dedupe key or the limit is right

During an Incident

A timeline covers one send. For a wider view, start from the delivery records and pull timelines for the failures:

scripts/failures.ts
const since = Date.now() - 60 * 60_000
const failed = (await notify.deliveries.list(undefined, undefined, 500)).filter(
	(d) => d.status === "failed" && d.failedAt && d.failedAt.getTime() > since,
)

for (const d of failed.slice(0, 10)) {
	const events = await notify.timeline(d.notificationRecordId, { deliveryId: d.id })
	const error = events.findLast((e) => e.event === "provider.error")
	console.log(d.channel, d.recipientId, error?.message)
}

For rates and alerts over time, use hooks instead of reading timelines.

Storage and Retention

  • The memory adapter and both Drizzle adapters store timelines. A custom adapter without the optional timeline section simply has none.
  • Events are kept for 7 days and pruned now and then as new ones are written. Change that with timelineRetentionMs; 0 turns pruning off, and notify.pruneTimeline(olderThan?) then prunes on demand.
  • A failed timeline write never fails the send. It goes to onTimelineError, which logs to the console by default.
  • Messages and metadata hold ids, reasons, and error text, not the payload, but inbox titles appear in inbox.created messages.

In Tests

An outcome can be right for the wrong reason. Asserting on the timeline checks the path, for example that a fallback fired because the primary failed rather than because it was skipped:

const events = (await notify.timeline(result.notification!.id)).map((e) => e.event)
const failed = events.indexOf("delivery.failed")
expect(failed).toBeGreaterThan(-1)
expect(events.indexOf("fallback.triggered")).toBeGreaterThan(failed)