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.
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.
- Ejecutar
wrangler dev --test-scheduledy disparar el handler bajo demanda. - Invocar el endpoint local
/__scheduledcon los parámetroscronytime. - 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.
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"
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.
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
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.
- Arranca
wrangler dev --test-scheduledy dispara tu handler concurl; confirma en la terminal que corrió de verdad. - Tu Worker ramifica con
switch (controller.cron): construye las URLs de/__scheduledque ejercitan cada una de sus ramas. - Simula una ejecución del 1 de enero pasando
timey verifica que tu lógica dependiente de fecha se comporta como esperas. - 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.