Skip to content
LastPing
GUIDE · INNGEST · CRON FUNCTIONS · FREE

Monitor Inngest functions: alerts for failed and missed cron runs

Inngest stores your cron schedule and calls your app's serve() endpoint when a run is due, retries failed steps, and calls an onFailure handler once retries are exhausted. That handler is another function served by the same app, and a function that is paused, archived or no longer synced starts no runs at all. Add a final step that pings LastPing and an onFailure handler that pings /fail, give the monitor the same cron, and a run that fails or never happens is reported from outside Inngest.

Your assistant can do this for you

Monitor my Inngest functions with LastPing

On this page
  1. Setup: a final step and an onFailure handler
  2. What this catches
  3. Questions people ask

Setup: a final step and an onFailure handler

Ping in the last step, ping /fail from the failure handler. A matching monitor schedule catches the run that never happened.

Create a heartbeat monitor in LastPing

In the console, create a monitor with the same cron as your Inngest function (Inngest crons run in UTC unless you prefix a TZ= timezone, so match it). Set the grace window to the function's expected runtime plus a margin, plus any jitter you configured. Copy the ping URL.

https://ping.lastping.dev/<your-monitor-id>

TypeScript: a final step.run and onFailure

onFailure runs once the function has failed after its last retry and receives the error. Wrap both pings in step.run so each is a durable step with its own retries:

import { cron } from "inngest";
import { inngest } from "./client";

const PING = "https://ping.lastping.dev/<your-monitor-id>";

export const nightlyEtl = inngest.createFunction(
  {
    id: "nightly-etl",
    triggers: [cron("0 3 * * *")],
    onFailure: async ({ error, step }) => {
      await step.run("ping-lastping-fail", async () => {
        await fetch(`${PING}/fail`, {
          method: "POST",
          body: error.message.slice(0, 10000),
        });
      });
    },
  },
  async ({ step }) => {
    await step.run("extract", extract);   // your work
    await step.run("ping-lastping", async () => {
      await fetch(PING);
    });
  }
);

Python: on_failure and ctx.step.run

The Python SDK takes the failure handler as on_failure; it receives the inngest/function.failed event, whose data carries the error:

import httpx
import inngest

PING = "https://ping.lastping.dev/<your-monitor-id>"

async def ping_failure(ctx: inngest.Context) -> None:
    error = ctx.event.data.get("error")
    message = error.get("message") if isinstance(error, dict) else "failed"

    async def send() -> None:
        async with httpx.AsyncClient() as client:
            await client.post(f"{PING}/fail", content=str(message)[:10_000], timeout=10)

    await ctx.step.run("ping-lastping-fail", send)

@inngest_client.create_function(
    fn_id="nightly-etl",
    trigger=inngest.TriggerCron(cron="0 3 * * *"),
    on_failure=ping_failure,
)
async def nightly_etl(ctx: inngest.Context) -> None:
    await ctx.step.run("extract", extract)   # your work

    async def ping() -> None:
        async with httpx.AsyncClient() as client:
            await client.get(PING, timeout=10)

    await ctx.step.run("ping-lastping", ping)

The Go SDK has no per-function failure handler; use a function triggered by inngest/function.failed and filter on the function's id instead. The POSTed body (up to 10,000 bytes) is stored with the fail ping.

The run that never happens needs no code

A sync records which functions your app serves, and Inngest starts no new runs for a paused function or an archived app. If a deploy drops or renames the function and the app resyncs, a pause from maintenance is never lifted, or Inngest cannot reach your serve() endpoint, the final step never runs. LastPing receives no ping, and the incident opens on its own when the grace window ends.

What this catches

  • A function fails after its retries: onFailure pings /fail with the error; you're paged immediately.
  • A run cancelled by a timeout: the final step never runs, so no success ping arrives and the incident opens.
  • A function left paused: no new runs start; the missing check-in is caught.
  • A deploy that drops the function, or an app Inngest cannot reach: no successful run, no ping, incident.
  • Recovery: the next successful run closes the incident automatically.

Comparing orchestrators? See Monitoring failed workflows across orchestrators. Using Trigger.dev instead? See Trigger.dev tasks. Hatchet? See Hatchet workflows. For the base pattern, see Monitor Node.js scripts.

Questions people ask

Front-loaded answers: the most important fact first.

  • Can't Inngest already alert me when a function fails?

    For failures, yes, if you build it: an onFailure handler, or a function triggered by the inngest/function.failed system event, runs after a function exhausts its retries, and the Datadog integration can alert on Inngest metrics. None of these fire for a run that never happens. A paused function starts no new runs, a function removed from your code is gone after the app's next sync, and the failure handler is another function in your app, so it cannot run when Inngest cannot reach your app. A dead-man's-switch expects a check-in on every scheduled run and opens an incident when one is missing.

  • How do I add a dead-man's-switch to an Inngest function?

    Make the last step of the function a step.run that GETs https://ping.lastping.dev/<id>, and add an onFailure handler (on_failure in Python) that POSTs the error to the /fail path. Create a LastPing monitor with the function's cron so a run that never starts also alerts.

  • Why ping inside step.run instead of at the end of the handler?

    Inngest saves each completed step's result and retries only failed work, so an HTTP call wrapped in step.run is a durable step with its own retries. Inngest's docs name calls to an external API as a use for step.run, and a ping is one.

  • What about runs cancelled by a timeout?

    Inngest can cancel a run that waits too long to start (timeouts.start) or runs too long (timeouts.finish). A cancelled run emits inngest/function.cancelled rather than inngest/function.failed, so an onFailure handler is not where it shows up. Either way the final success step never runs, so LastPing sees no ping and opens the incident when the grace window ends.

  • Is this free?

    Yes. LastPing is free for individuals. See vs Healthchecks.io and vs Cronitor.

FREE FOR INDIVIDUALS · FULLY HOSTED

A paused function fails nothing. Notice it anyway.

One final step, one failure handler and a matching schedule. First alert in under a minute, and LastPing monitors itself the same way.