BackDebugging and testing

Explain and dry run

See exactly what a send would do, channel by channel, without storing or delivering anything.

Which Tool

You want to knowUse
What would happen if I sent this now?explain(), on this page
What did happen to a send?timeline(), with the notification record id
What happened in the call I just made?The SendResult: skipped, deferredChannels, rateLimited, digested

Using It

explain() takes exactly what send() takes and runs the same checks, but writes nothing and calls no provider. It is safe to run in production.

const explanation = await notify.explain({
	recipientId: "user_123",
	notificationId: "comment_mentioned",
	payload: { actorName: "Rey", postTitle: "Launch Plan", postUrl: "/posts/42" },
})

notify.send({ ...input, dryRun: true }) and notify.check() do the same thing. Like send(), it throws for an unknown notification id or a recipient you have not created with upsertRecipient(). Idempotency and deduplication are only checked if you pass the same idempotencyKey or dedupeKey the real send would use.

What Comes Back

{
	recipientId: "user_123",
	notificationId: "comment_mentioned",
	required: false,
	payloadValidation: { valid: true, fields: [] },
	wouldReplayIdempotent: false,
	wouldDeduplicate: false,
	wouldRateLimit: false,
	wouldDigest: false,
	rateLimit: { current: 7, max: 30, windowMs: 3_600_000 },
	quietHours: { active: false, resumesAt: null },
	channels: [
		{
			channel: "inbox",
			outcome: "deliver",
			allowed: true,
			resolvedBy: "app_default",
			...
		},
		{
			channel: "email",
			outcome: "disabled",
			allowed: false,
			resolvedBy: "user_notification",
			reason: "...",
			trail: [
				{ layer: "app_default", value: true },
				{ layer: "notification_default", value: undefined },
				{ layer: "category_default", value: undefined },
				{ layer: "tenant_setting", value: undefined },
				{ layer: "user_global", value: undefined },
				{ layer: "user_category", value: undefined },
				{ layer: "user_notification", value: false }, // turned off here
			],
		},
	],
}
FieldTells you
channelsOne entry per channel: its outcome, whether preferences allow it, and the trail that decided it
payloadValidationWhether the payload passes, with a fields list of what is wrong
wouldReplayIdempotentThe idempotency key was used before; idempotency has the earlier record id
wouldDeduplicateThe dedupe key is still inside its window; dedupe has the details
wouldRateLimitThe limit is reached; rateLimit has the current count
wouldDigestThe notification has a digest, so this send would be batched; digest has the window
quietHoursactive and resumesAt, or null if the recipient has none
requiredThe notification ignores preferences

Channel Outcomes

Each channel gets the first outcome that applies, in this order. Preferences come first, so a channel the user turned off shows disabled even when the whole send would also be rate-limited.

OutcomeMeaningWhat to do
unavailableNo address: the recipient has no email or phoneAsk the user for one
disabledPreferences turn this channel offRead the trail
invalid_payloadThe payload fails validationSee payloadValidation.fields
idempotentThis exact send already happenedNothing; the earlier result would be returned
deduplicatedThe same event was sent inside the windowNothing, unless the key is too broad
rate_limitedOver the limit for the windowWait, or raise the limit
digestedWould join a batchIt arrives when the digest flushes
delayedEmail, SMS, or webhook held by quiet hoursIt arrives at quietHours.resumesAt
deliverWould be delivered nowNothing

The Preference Trail

trail lists every preference layer in order, from the app default to the user’s setting for this notification. undefined means the layer has no opinion. The last layer with a value wins and is named in resolvedBy, unless one of two layers is added at the end:

  • required_override: the notification is required, so the channel is allowed whatever the user chose.
  • destination_unavailable: there is nowhere to send it, so it is off even when required.

For the trail without the rest of the pipeline, use notify.preferences.explain(), which takes a recipient, a notification id, and an optional scope. See Preferences.

Debugging a Missing Message

  1. Run explain() with the inputs the original send used.
  2. If wouldRateLimit, wouldDeduplicate, or wouldReplayIdempotent is true, the send stopped before any channel.
  3. Otherwise find the channel in channels and read its outcome.
  4. For disabled, the trail shows which layer turned it off.
scripts/diagnose.ts
const e = await notify.explain(input)
const blocked = e.channels
	.filter((ch) => ch.outcome !== "deliver")
	.map((ch) => `${ch.channel}: ${ch.outcome} (${ch.resolvedBy})`)
console.log(blocked.length ? blocked : "Every channel would deliver")

If explain() says deliver but the message never arrived, the problem came later, at the provider. Pull the timeline for that send.

Support Tools

An explanation is a good basis for an internal “why didn’t this user get it?” page. Put it behind admin auth: it reveals a user’s preferences and rate-limit counts.

app/api/admin/diagnose/route.ts
export async function GET(request: Request) {
	await requireAdmin(request)
	const url = new URL(request.url)
	const explanation = await notify.explain({
		recipientId: url.searchParams.get("user")!,
		notificationId: url.searchParams.get("notification")! as "comment_mentioned",
		payload: samplePayload,
	})
	return Response.json({
		wouldDeliver: explanation.channels.some((ch) => ch.outcome === "deliver"),
		channels: explanation.channels.map(({ channel, outcome, resolvedBy }) => ({
			channel, outcome, resolvedBy,
		})),
		quietHoursUntil: explanation.quietHours?.resumesAt ?? null,
	})
}

The HTTP handler also serves GET /explain and GET /preferences/explain, limited to the signed-in user, for a preferences page that shows what a toggle would change.

In Tests

Because it has no side effects, explain() is the quickest way to assert on routing: which channels a send would use and why, without faking providers or cleaning up records.

tests/routing.test.ts
it("respects an email opt-out", async () => {
	await notify.upsertRecipient({ id: "u1", email: "a@example.com" })
	await notify.preferences.update({
		recipientId: "u1",
		notificationId: "comment_mentioned",
		channels: { email: false },
	})

	const e = await notify.explain({
		recipientId: "u1",
		notificationId: "comment_mentioned",
		payload,
	})
	const email = e.channels.find((ch) => ch.channel === "email")
	expect(email?.outcome).toBe("disabled")
	expect(email?.resolvedBy).toBe("user_notification")
})

Use a real send() when you want to check what was delivered. See Testing and dev mode.