Back to 0cron
0cron

Monitoring heartbeat : quand votre tâche devrait vous pinguer

L'inverse des tâches planifiées : donnez à votre cron une URL à pinguer, et 0cron vous alerte quand le ping s'arrête. Périodes de grâce, génération de tokens, et arithmétique d'intervalles PostgreSQL.

Thales & Claude | March 30, 2026 7 min 0cron
EN/ FR/ ES
0cronmonitoringheartbeatrustpostgresqlalerting

La plupart de 0cron fonctionne dans une direction : nous appelons vos endpoints selon un planning. Vous définissez une tâche, nous donnez une URL et une expression cron, et nous faisons la requête HTTP au bon moment. Simple.

Mais il existe une catégorie de problèmes où la direction doit s'inverser. Vous avez un script de sauvegarde qui tourne sur votre propre serveur. Un pipeline CI qui devrait se terminer chaque heure. Une tâche de synchronisation de données gérée par un service tiers. Vous ne pouvez pas pointer 0cron vers ceux-là parce que vous ne contrôlez pas leur invocation -- ils tournent déjà ailleurs. Ce que vous avez besoin de savoir, c'est s'ils tournent encore. S'ils ont terminé. Si quelque chose a cassé à 3 h du matin et que personne ne s'en est aperçu avant lundi.

C'est le monitoring heartbeat, et c'est intégré dans 0cron comme fonctionnalité de première classe. Le concept est simple : nous vous donnons une URL. Votre tâche pingue cette URL quand elle termine. Si nous ne recevons pas de ping dans la fenêtre attendue plus une période de grâce, nous vous alertons. Le silence signifie l'échec.

L'implémentation entière fait 105 lignes de Rust.

Le modèle de données

Chaque moniteur heartbeat doit suivre quelques éléments : qui le possède, quelle planification il attend, combien de marge accorder, et quand il a eu des nouvelles de la tâche pour la dernière fois. Voici le schéma PostgreSQL :

sqlCREATE TABLE monitors (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    team_id UUID REFERENCES teams(id),
    name VARCHAR(255) NOT NULL,
    ping_token VARCHAR(64) UNIQUE NOT NULL,
    schedule_cron VARCHAR(100) NOT NULL,
    grace_period_seconds INTEGER DEFAULT 300,
    timezone VARCHAR(50) DEFAULT 'UTC',
    status VARCHAR(20) DEFAULT 'active',
    last_ping_at TIMESTAMPTZ,
    notification_config JSONB,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

Les champs clés méritent une explication.

ping_token est une chaîne hexadécimale unique de 64 caractères. C'est l'identifiant intégré dans l'URL de ping. Quand votre tâche appelle https://0cron.dev/v1/ping/abc123def..., le token correspond à ce moniteur. Nous utilisons des tokens plutôt que des ID de moniteur pour deux raisons : les tokens sont non devinables (ce sont 32 octets aléatoires, encodés en hexadécimal), et ils découplent l'endpoint de ping du modèle de données interne.

grace_period_seconds est par défaut à 300 (5 minutes). C'est la fenêtre après l'heure de ping attendue pendant laquelle nous n'alertons pas. Les tâches de sauvegarde prennent un temps variable. La latence réseau existe. Une tâche qui s'exécute à 2 h 00 et pingue à 2 h 04 n'est pas en retard -- elle est dans la tolérance.

last_ping_at est nullable. Un tout nouveau moniteur n'a jamais reçu de ping. Cet état null est important -- nous n'alertons pas sur les moniteurs qui n'ont jamais pingué, parce que l'utilisateur est peut-être encore en train de configurer son intégration.

Création d'un moniteur

rustpub async fn create_monitor(
    team_id: Uuid, name: &str, schedule_cron: &str,
    grace_period_seconds: i32, timezone: &str,
) -> AppResult<Monitor> {
    schedule_cron.parse::<cron::Schedule>()
        .map_err(|e| AppError::Validation(format!("Invalid cron expression: {e}")))?;
    let ping_token = generate_ping_token();
    Ok(Monitor {
        id: Uuid::new_v4(), team_id, name: name.to_string(),
        ping_token, schedule_cron: schedule_cron.to_string(),
        grace_period_seconds, timezone: timezone.to_string(),
        status: "active".to_string(), last_ping_at: None,
        notification_config: None, created_at: Utc::now(),
    })
}

La validation de l'expression cron se fait immédiatement. C'est un pattern que nous utilisons partout dans 0cron : valider à la frontière, pas dans le pipeline de traitement.

Génération de token

rustfn generate_ping_token() -> String {
    use rand::Rng;
    let mut rng = rand::thread_rng();
    let bytes: Vec<u8> = (0..32).map(|_| rng.gen()).collect();
    hex::encode(bytes)
}

32 octets aléatoires, encodés en hexadécimal vers 64 caractères. C'est 256 bits d'entropie. Pour mettre en contexte, il y a approximativement 10^77 tokens possibles -- plus que le nombre estimé d'atomes dans l'univers observable. Le brute-force d'un token de ping n'est pas un vecteur d'attaque pratique.

Enregistrement des pings

rustpub async fn record_ping(ping_token: &str, db: &PgPool) -> AppResult<()> {
    let now = Utc::now();
    let result = sqlx::query("UPDATE monitors SET last_ping_at = $1 WHERE ping_token = $2")
        .bind(now).bind(ping_token).execute(db).await?;
    if result.rows_affected() == 0 {
        return Err(AppError::NotFound(format!("Monitor with token '{ping_token}' not found")));
    }
    Ok(())
}

Un seul statement SQL. L'endpoint API qui appelle cette fonction est GET /v1/ping/{token}. Oui, GET, pas POST. Les requêtes GET sont la requête HTTP la plus simple possible. Elles fonctionnent avec curl https://0cron.dev/v1/ping/TOKEN -- pas de flags, pas de corps, pas d'en-tête content-type.

Détection des moniteurs en retard

Le coeur du système est la vérification des retards :

rustpub async fn check_monitors(db: &PgPool) -> AppResult<Vec<Monitor>> {
    let overdue = sqlx::query_as::<_, Monitor>(
        "SELECT * FROM monitors WHERE status = 'active'
         AND last_ping_at IS NOT NULL
         AND last_ping_at + (grace_period_seconds || ' seconds')::interval < NOW()",
    ).fetch_all(db).await?;
    Ok(overdue)
}

L'expression last_ping_at + (grace_period_seconds || ' seconds')::interval construit un horodatage représentant « la dernière heure de ping plus la période de grâce ». Si l'heure actuelle (NOW()) dépasse cette échéance, le moniteur est en retard.

La clause last_ping_at IS NOT NULL est le guard « jamais pingué ». Un nouveau moniteur qui n'a pas encore reçu son premier ping n'est pas considéré en retard.

Les cas d'usage

Tâches de sauvegarde. Votre sauvegarde de base de données tourne via crontab sur votre propre serveur. Vous ajoutez curl https://0cron.dev/v1/ping/TOKEN comme dernière ligne du script. Si la sauvegarde échoue, plante, ou si le serveur tombe, le curl ne s'exécute jamais, et 0cron vous alerte.

Pipelines CI/CD. Un pipeline de déploiement devrait se terminer en 30 minutes. Ajoutez un ping à la fin du pipeline, réglez la période de grâce à 1 800 secondes. Si un déploiement reste bloqué, l'absence de ping déclenche une alerte.

Tâches planifiées dans les frameworks applicatifs. Django a les management commands. Rails a les rake tasks. Laravel a les commandes planifiées. Ajouter un ping à la fin de chaque tâche crée un chien de garde externe qui ne dépend pas de la santé de l'application elle-même.

Périodes de grâce : l'art de la tolérance

La période de grâce est la différence entre un moniteur utile et un moniteur agaçant. La valeur par défaut de 300 secondes (5 minutes) fonctionne pour la plupart des tâches courtes. Mais la bonne valeur dépend de la tâche.

Nous n'avons délibérément pas implémenté de périodes de grâce « intelligentes » qui s'auto-ajustent basées sur l'historique de timing des pings. Ce genre de fonctionnalité semble séduisant mais introduit de l'imprévisibilité. Quand un ingénieur d'astreinte règle une période de grâce de 5 minutes, il veut exactement 5 minutes.

105 lignes

L'intégralité de la fonctionnalité de monitoring heartbeat -- modèle de données, génération de token, enregistrement de ping, détection de retard -- fait 105 lignes de Rust.

C'est possible parce que nous avons pris des décisions de scope agressives. Un horodatage au lieu d'une table d'historique. GET au lieu de POST. Des périodes de grâce fixes au lieu de seuils adaptatifs. Une requête plate au lieu d'une machine à états en arrière-plan. Chaque décision a supprimé de la complexité sans supprimer de la valeur.

Quand quelqu'un demande comment une équipe de deux personnes (un CEO humain, un AI CTO) livre des fonctionnalités aussi vite, voici la réponse : choisir les bons outils, scoper impitoyablement, et n'écrire que le code que vos dépendances ne gèrent pas déjà.


Ceci est l'article 8 de 10 dans la série « Comment nous avons construit 0cron ».

  1. Pourquoi le monde a besoin d'un service cron à 2 $
  2. 4 agents, 1 produit : construire 0cron en une seule session
  3. Construire un moteur de planification cron en Rust
  4. "Tous les jours à 9 h" : parsing de planification en langage naturel
  5. Notifications multi-canaux : e-mail, Slack, Discord, Telegram, webhooks
  6. Intégration Stripe pour un SaaS à 1,99 $/mois
  7. Du HTML statique au tableau de bord SvelteKit en une nuit
  8. Monitoring heartbeat : quand votre tâche devrait vous pinguer (vous êtes ici)
  9. Secrets chiffrés, clés API, et sécurité
  10. D'Abidjan à la production : lancement de 0cron.dev
Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles