wandres.dev
CRON TRIGGERS · workers programados

Probar y observar un cron

No puedes esperar a las tres de la madrugada para depurar un cron. wrangler dev con --test-scheduled expone el endpoint /__scheduled para dispararlo a voluntad y simular cualquier horario; en producción, wrangler tail y el panel de Cron Events del dashboard son la prueba de que corrió, cuándo y con qué resultado.

⏱ 13 min

Un cron programado para 0 3 * * * plantea un problema práctico incómodo: no vas a esperar a las tres de la madrugada UTC para ver si tu handler funciona, y menos aún en cada iteración del desarrollo. La tarea programada es, por naturaleza, difícil de observar: nadie la pide, nadie recibe su respuesta, y tanto su éxito como su fracaso son silenciosos. Esta lección cierra ese hueco por los dos extremos: en local, cómo disparar el scheduled handler bajo demanda y simular cualquier expresión; en producción, cómo leer la huella que deja cada ejecución para saber que corrió, cuándo y si terminó bien.

🎯 Al terminar esta lección sabrás
  • Ejecutar wrangler dev --test-scheduled y disparar el handler bajo demanda.
  • Invocar el endpoint local /__scheduled con los parámetros cron y time.
  • Leer los registros de una ejecución real con wrangler tail.
  • Auditar ejecuciones y fallos en el panel de Cron Events del dashboard.

Disparar el reloj a voluntad

Normalmente wrangler dev levanta tu Worker y sirve el handler fetch. Para probar scheduled necesitas una puerta que, en local, imite al programador de Cloudflare. Esa puerta la abre una bandera:

npx wrangler dev --test-scheduled

Con ella, el servidor de desarrollo registra una ruta especial, /__scheduled, que al recibir una petición invoca tu scheduled handler como si el cron acabara de dispararse. El bucle de desarrollo se vuelve inmediato: editas, guardas, golpeas el endpoint y lees los console.log en la misma terminal.

curl "http://localhost:8787/__scheduled"

El servidor de desarrollo imprime además el desenlace de cada disparo —si el handler resolvió limpiamente o lanzó una excepción— junto a tus propios registros, así que un fallo no pasa inadvertido mientras iteras. Editas, guardas, golpeas el endpoint y lees el resultado sin salir de la terminal.

⚠️
/__scheduled es solo local

Esa ruta existe únicamente en el servidor de desarrollo. Tu Worker desplegado no expone ningún /__scheduled ni ninguna otra vía para que un extraño dispare tu cron desde fuera: en producción, disparar el scheduled handler es competencia exclusiva del programador de Cloudflare. La usas para depurar, no la conviertes en un endpoint de tu API.

El disparo manual es perfecto para iterar, pero no para una suite que corra en integración continua. Para eso, la integración de Vitest para Workers te deja invocar el handler programado desde una prueba y afirmar sobre sus efectos —lo que escribió, lo que encoló— sin tocar la red ni esperar a un horario:

import { env, SELF } from "cloudflare:test";
import { expect, it } from "vitest";

it("el cron deja su latido", async () => {
  const r = await SELF.scheduled({ cron: "0 8 * * *" });
  expect(r.outcome).toBe("ok");
  expect(await env.KV.get("cron:ultimo-ok")).not.toBeNull();
});

El endpoint /__scheduled

Cuando un mismo Worker atiende varios horarios y ramifica con switch (controller.cron), necesitas ejercitar cada rama por separado. El endpoint acepta dos parámetros de consulta para ello. cron fija el controller.cron —y por tanto selecciona la rama—; time fija el controller.scheduledTime en milisegundos desde época, para probar lógica que dependa de la fecha de forma determinista.

# simula el trigger de las 08:00 (0 8 * * *); el espacio se codifica como +
curl "http://localhost:8787/__scheduled?cron=0+8+*+*+*"
# fija ademas el instante programado, para logica dependiente de la fecha
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*&time=1735732800000"
📝
Codifica bien la expresión

Una expresión cron va llena de espacios y asteriscos, y ambos son delicados en una URL. Codifica cada espacio como + o %20; los asteriscos son válidos tal cual en la cadena de consulta. Y si tu shell intenta expandir el * contra los ficheros del directorio, entrecomilla la URL entera —como en los ejemplos— para que llegue literal al servidor.

🎯

cron=

La expresión que quieres simular. Alimenta controller.cron y selecciona la rama de tu switch.

🕰️

time=

Milisegundos desde época. Fija controller.scheduledTime para probar lógica que depende de la fecha.

sin parámetros

Invoca el handler con el primer cron declarado y la hora actual. El disparo más rápido.

Así conviertes un handler dependiente del reloj en algo tan comprobable como una función pura: cada rama tiene su URL, cada fecha crítica su time, y ninguna prueba exige que el mundo llegue a una hora concreta.

Y como el endpoint responde con normalidad, puedes automatizarlo: un pequeño script que recorra tus expresiones y golpee /__scheduled con cada una es una prueba de humo barata que ejercita todas las ramas de tu switch en segundos.

Observar la ejecución real

Lo que corre en local no garantiza lo que corre desplegado. En producción dispones de dos lentes complementarias. La primera es en vivo: wrangler tail transmite los eventos de tu Worker según ocurren, y cada disparo del cron aparece como un evento con su resultado.

npx wrangler tail --format pretty

Verás tus console.log, cualquier excepción lanzada y el desenlace de la invocación —ok, canceled, o un error de límite—. Es la herramienta para atrapar un cron en el acto, especialmente si su cadencia es corta y no quieres esperar al panel.

Conviene saber leer esos desenlaces, porque cada uno cuenta una historia distinta: ok es una invocación que terminó sin lanzar; canceled suele indicar que se cortó antes de completar —por ejemplo, al superar el techo de tiempo—; y un desenlace de excepción apunta a un throw sin capturar en tu código. Ninguno dice lo mismo sobre por qué tu cron no dejó el efecto que esperabas.

La segunda lente es histórica: en el dashboard, dentro de tu Worker, el panel de Cron Events lista las ejecuciones programadas recientes con su marca de tiempo, la expresión que las disparó, el estado —éxito o error— y la duración. Es el registro de auditoría que responde a las tres preguntas que de verdad importan: ¿corrió a la hora prevista?, ¿terminó bien?, ¿cuánto tardó?

Conviene recordar que Cron Events conserva un historial reciente, no eterno: es un panel para vigilancia continua y diagnóstico a corto plazo, no un archivo permanente. Si necesitas auditoría a largo plazo, exporta tus propios registros con la telemetría que dejas viva en ctx.waitUntil.

Entre --test-scheduled para explorar a mano, la prueba en integración continua para blindar contra regresiones y Cron Events para vigilar producción, tienes el cron cubierto en sus tres fases: desarrollo, integración y operación.

Ninguna de las dos lentes sirve de nada si tu handler no deja rastro propio. El hábito que se paga solo es la autoinstrumentación: que cada ejecución escriba, como último gesto, una huella durable —un latido— con la que después puedas afirmar no solo si hubo errores, sino si hubo ejecución.

async scheduled(controller, env, ctx) {
  try {
    await tareaPrincipal(env);
    ctx.waitUntil(
      env.KV.put("cron:ultimo-ok", String(controller.scheduledTime)),
    );
  } catch (e) {
    console.error("cron fallo", e);
    throw e; // que el desenlace en Cron Events sea un error visible
  }
}

Ese throw final es deliberado: al propagar la excepción, la invocación se marca como error en Cron Events en lugar de fingir un éxito. Y el latido en KV habilita la alerta que de verdad protege un cron —la de la ausencia—: si cron:ultimo-ok no se ha movido en el plazo esperado, es que algo dejó de dispararse, y esa es justo la avería que nadie te va a reportar.

ℹ️
tail muestrea bajo mucha carga

wrangler tail transmite en vivo, pero cuando el volumen de eventos es alto la plataforma muestrea para no saturar el canal, y puede que no veas cada invocación. En un Worker con mucho tráfico simultáneo, no tomes la ausencia de una línea en tail como prueba de que el cron no corrió; para eso está Cron Events, que registra las ejecuciones programadas sin muestrearlas.

flowchart TB
subgraph Local
  Editar[editar y guardar] --> Curl[curl a __scheduled]
  Curl --> Term[logs en la terminal]
end
subgraph Produccion
  Cron[el cron dispara] --> Tail[wrangler tail en vivo]
  Cron --> Panel[Cron Events historico]
end
Lo invisible hay que hacerlo visible a propósito

De todas las piezas de un sistema, el cron es la más difícil de confiar, y por una razón sutil: su corrección no es una propiedad del código sino una propiedad temporal. No basta con que el handler sea correcto; tiene que haber corrido, a su hora, y haber terminado. Nada de eso se verifica leyendo el fuente: se verifica observando la línea del tiempo. Y aquí acecha la trampa psicológica del silencio. Una petición que falla devuelve un 500 que alguien ve; un usuario molesto abre un ticket; el APM ligado a peticiones se enciende. Un cron que falla no devuelve nada a nadie —no hay cliente, no hay respuesta— y su fracaso más peligroso no es la excepción ruidosa, sino la ausencia: el trabajo que simplemente dejó de dispararse. El desastre clásico es un cron que se detuvo hace tres semanas y del que nadie se percató hasta que los informes dejaron de llegar. Por eso la disciplina profesional invierte la intuición: trata «ninguna noticia» como sospechoso, no como tranquilizador. Instrumenta cada ejecución con telemetría que dejas viva con ctx.waitUntil, vigila Cron Events como fuente de verdad, y —cuando el trabajo es crítico— alerta sobre la ausencia de ejecuciones, no solo sobre los errores. --test-scheduled te da determinismo en desarrollo; Cron Events te da la verdad del terreno en producción. Entre los dos, conviertes una tarea fantasma en algo que puedes afirmar que funciona, en vez de suponerlo.

⚔️ Prueba y vigila tu cron
  1. Arranca wrangler dev --test-scheduled y dispara tu handler con curl; confirma en la terminal que corrió de verdad.
  2. Tu Worker ramifica con switch (controller.cron): construye las URLs de /__scheduled que ejercitan cada una de sus ramas.
  3. Simula una ejecución del 1 de enero pasando time y verifica que tu lógica dependiente de fecha se comporta como esperas.
  4. Despliega, abre Cron Events en el dashboard y describe qué tres datos te da de cada ejecución; explica cómo detectarías que un cron dejó de dispararse.