GUIDE · TEMPORAL · FREE

Monitor Temporal workflows: activity heartbeat and LastPing completion ping

In the @workflow.run method, wrap execute_activity in try/except: on success execute a dedicated ping activity that GETs the LastPing URL; on exception execute a fail-ping activity that POSTs to /fail. Use activity.heartbeat() to prevent Temporal from timing out long activities. Absence detection covers a down worker. Free, no monitor cap.

The snippets

Create a monitor in the console, set the schedule to match your Temporal cron_schedule plus a grace window, and add the ping activities below.

Python SDK — ping as a dedicated activity

Temporal replays workflow code deterministically, so HTTP calls must go inside activities, not directly in the workflow body. A small ping_lastping activity is the correct pattern:

import httpx from datetime import timedelta from temporalio import activity, workflow from temporalio.client import Client from temporalio.worker import Worker PING = "https://ping.lastping.dev/<your-monitor-id>" @activity.defn async def ping_lastping(suffix: str = "", body: str = "") -> None: async with httpx.AsyncClient() as client: if body: await client.post( f"{PING}{suffix}", content=body[:10_000].encode(), timeout=10, ) else: await client.get(f"{PING}{suffix}", timeout=10) @activity.defn async def run_etl() -> None: # your ETL work for i, chunk in enumerate(get_chunks()): process(chunk) activity.heartbeat(f"chunk {i} done") # keeps Temporal's timeout alive @workflow.defn class DailyEtlWorkflow: @workflow.run async def run(self) -> None: try: await workflow.execute_activity( run_etl, schedule_to_close_timeout=timedelta(hours=2), ) await workflow.execute_activity( ping_lastping, schedule_to_close_timeout=timedelta(seconds=30), ) except Exception as e: await workflow.execute_activity( ping_lastping, args=["/fail", str(e)[:10_000]], schedule_to_close_timeout=timedelta(seconds=30), ) raise

Registering the workflow with a cron schedule

Pass cron_schedule to start_workflow so Temporal re-runs the workflow on the given cron expression. Each run is independent — the workflow function starts fresh each time:

async def main(): client = await Client.connect("localhost:7233") await client.start_workflow( DailyEtlWorkflow.run, id="daily-etl-workflow", task_queue="etl-queue", cron_schedule="0 2 * * *", # 02:00 UTC every day ) async with Worker( client, task_queue="etl-queue", workflows=[DailyEtlWorkflow], activities=[run_etl, ping_lastping], ): await asyncio.sleep(float("inf")) # keep worker running if __name__ == "__main__": asyncio.run(main())

activity.heartbeat() vs. LastPing ping — two different things

activity.heartbeat() is an internal keepalive that prevents Temporal from declaring the activity timed out. A LastPing ping is an external signal to your team that the workflow completed on schedule. Both are needed for long-running workflows: heartbeats keep the activity alive; LastPing tells you when it's done. See also the Airflow guide for a DAG-level comparison and the LastPing MCP server for AI-agent provisioning.

Grace window alignment

Set the LastPing grace window to at least schedule_to_close_timeout + retry_budget so Temporal's built-in retries don't open a spurious incident. Only the final failure (after Temporal exhausts retries) should trigger the /fail ping from the workflow's except block.


What this catches

The activities report what they can; the silence reports the rest.

  • Workflow exception after retries — the except block executes the fail-ping activity, opening an incident immediately with the error text.
  • Successful workflow completion — the ping activity fires after the last business activity, resetting the absence timer.
  • Worker is down — no activity is polled; the workflow stays scheduled but never runs; the missing ping opens the incident after the grace window.
  • Activity timeout — Temporal marks the activity as timed out and the workflow's except block fires the fail-ping activity.
  • Recovery — the next successful workflow run fires the success ping activity, closing the incident automatically.

Questions people ask

Front-loaded answers — the most important fact first.

  • Can I call fetch/httpx directly in the workflow body?

    No. Temporal replays workflow code deterministically; side effects including HTTP calls must go inside activities. Wrap the ping in a dedicated @activity.defn function and call it with workflow.execute_activity.

  • What is the difference between activity.heartbeat and a LastPing ping?

    activity.heartbeat() is an internal keepalive to Temporal's server — it tells Temporal the activity is still running. A LastPing ping is an external completion signal to your team. Both are useful for long-running workflows: one prevents Temporal timeouts, the other prevents silence going unnoticed.

  • What if the Temporal worker process is killed mid-activity?

    Temporal schedules the activity for retry on another worker. If no worker is available (all workers are down), the activity stays in the task queue and no success ping fires. Absence detection opens the incident after the grace window.

  • Is this free?

    Yes. LastPing is free and fully hosted with no monitor cap and no paid tier. See how it compares to Healthchecks.io and Cronitor.

FREE · FULLY HOSTED · NO PAID TIER

Temporal retries silently. LastPing tells you when they're exhausted.

One ping activity now beats debugging a failed nightly workflow later. First alert wired up in about a minute — and LastPing monitors itself the same way.