BackDebugging and testing

Hooks and observability

Feed lifecycle events into metrics, audit logs, error tracking, and alerts.

Configuring

Pass handlers in on. Each gets one context object, and can be async.

lib/notifykit.ts
export const notify = createNotifyKit({
	// ...
	on: {
		"delivery.sent": ({ delivery }) => {
			metrics.increment("notifykit.sent", {
				channel: delivery.channel,
				provider: delivery.provider,
			})
		},
		"delivery.failed": ({ delivery, error }) => {
			Sentry.captureException(error, { extra: { deliveryId: delivery.id } })
		},
		"notification.rate_limited": ({ notificationId, recipientId, limit }) => {
			logger.warn("rate limited", {
				notificationId,
				recipientId,
				max: limit.max,
			})
		},
	},
	onHookError: (hook, error) => {
		logger.error(`notifykit hook ${hook} failed`, error) // keep sends going
	},
})

Available Hooks

Sends

HookRuns whenContext
notification.createdA notification record is written, including for sends that are then deduplicated, suppressed, or rate-limitednotification, redactedPayload
notification.deduplicatedThe dedupe key was seen inside its windownotificationId, recipientId, dedupeKey, windowMs
notification.suppressedEvery channel is off or has no address, and no fallback appliesnotificationId, recipientId, skippedChannels, skipped
notification.rate_limitedThe send is over its rate limitnotificationId, recipientId, limit

Deliveries

HookRuns whenContext
delivery.sentA provider accepted an email, SMS, or webhookdelivery, redactedPayload
delivery.failedNo attempts are left, or the error was permanent; before any fallbackdelivery, error, redactedPayload

There is no hook per retry. Individual attempts and errors are in the timeline.

Inbox

HookRuns whenContext
inbox.createdAn inbox item is written, including by a fallbackinboxItem
inbox.updatedAn item is marked readinboxItem
inbox.archived, inbox.unarchivedAn item is archived or restoredinboxItem
inbox.deletedAn item is deleteditemId, recipientId
inbox.all_readEverything is marked readrecipientId, count

Order in a Send

  1. notification.created runs as soon as the record exists, before any channel is handled. A send stopped by deduplication, preferences, or a rate limit still creates a record, so this hook runs first and the matching deduplicated, suppressed, or rate_limited hook runs after it.
  2. inbox.created runs when the inbox item is written, which happens before email and other channels are sent.
  3. delivery.sent or delivery.failed runs per delivery. With the default inlineQueue() that happens inside send(); with any other queue, later.

A send that joins a digest runs no hooks until the digest flushes. Channels held by quiet hours run their delivery hooks when they are finally sent.

When a Hook Throws

Hooks are awaited, and by default an error in one is re-thrown. That stops the rest of the send: a throwing inbox.created hook means the email is never queued, and send() rejects even though a record was stored.

Hook time also adds to send() time. For slow calls, start the work without awaiting it, and catch its errors yourself so they do not become unhandled rejections:

"delivery.sent": ({ delivery }) => {
	void analytics
		.track("notification_delivered", { channel: delivery.channel })
		.catch((err) => logger.warn("analytics failed", err))
},

Do not call notify.send() from a hook. A follow-up notification belongs in your job queue.

Redaction

redactedPayload is the payload with every field in the definition’s redact list replaced by "[REDACTED]", so it is safe to send to logs and third parties. notification.payload is the full payload; do not forward it.

notification({
	id: "password_changed",
	payload: { email: "string", ip: "string" },
	channels: [inbox({ title: "Your password was changed" })],
	redact: ["email", "ip"],
})

What to Measure

SignalFromWorth a look when
Delivery success ratedelivery.sent and delivery.failed, by providerIt drops below your usual rate: an outage, an expired key, a lost domain verification
Delivery latencydelivery.sentAt minus delivery.createdAtp95 rises: a slow provider or a backed-up queue
Rate-limit hitsnotification.rate_limitedThey spike: often a bug sending in a loop
Suppressionsnotification.suppressedThey climb: stale recipients or missing addresses
Duplicatesnotification.deduplicatedThey spike: a retry storm, or a key that is too broad
"delivery.sent": ({ delivery }) => {
	const ms = delivery.sentAt!.getTime() - delivery.createdAt.getTime()
	metrics.histogram("notifykit.latency_ms", ms, { channel: delivery.channel })
},

Most spikes follow a deploy, so plot deploy markers next to these. For a single failure, the timeline has the details.

Composing

on takes one function per hook. To keep metrics, error tracking, and audit logging in separate files, merge them yourself:

lib/hooks.ts
import type { Hooks } from "@notifykitjs/core"

export function mergeHooks(...sets: Hooks[]): Hooks {
	const merged: Record<string, (ctx: never) => Promise<void>> = {}
	for (const set of sets) {
		for (const [name, fn] of Object.entries(set)) {
			if (!fn) continue
			const prev = merged[name]
			merged[name] = async (ctx) => {
				await prev?.(ctx)
				await (fn as (ctx: never) => unknown)(ctx)
			}
		}
	}
	return merged as Hooks
}

// createNotifyKit({ on: mergeHooks(metricsHooks, errorHooks, auditHooks) })

With this version, a handler that throws stops the ones after it for that event; catch inside each handler if they must all run.