DevOps & Scaling

Pile ELK pour l'analyse des journaux de résolution CAPTCHA

La stack ELK transforme les logs bruts de votre pipeline CAPTCHA en tableaux de bord interrogeables : un pic d'erreurs TIMEOUT, une dérive de latence ou une URL cible qui échoue en boucle deviennent visibles en quelques secondes, sans jamais rouvrir un fichier .log à la main. Elasticsearch indexe chaque résolution, Logstash normalise le flux entrant et Kibana affiche les tendances — taux de réussite, répartition des erreurs, résolutions les plus lentes. Ce guide branche CaptchaAI sur cette chaîne, du worker qui émet du JSON structuré jusqu'au dashboard Kibana prêt à l'emploi.

Architecture : du worker CAPTCHA à Kibana

Le flux est unidirectionnel. Chaque worker écrit ses logs en JSON sur disque ; Filebeat les collecte et les pousse vers Logstash, qui parse, enrichit et route les événements vers Elasticsearch ; Kibana lit l'index et sert les visualisations. Chaque étage reste remplaçable : vous pouvez héberger le cluster sur OVHcloud, Scaleway ou une région AWS eu-west-3 (Paris) sans toucher au code des workers.

[CAPTCHA Workers] → JSON logs → [Filebeat] → [Logstash] → [Elasticsearch]
                                                                ↓
                                                           [Kibana]

Journalisation structurée des résolutions

Tout commence par le format des logs. Une ligne exploitable, c'est un objet JSON par événement, avec des champs typés : captcha_id, captcha_type, solve_time, error_code, poll_count et target_url. Le texte libre est précisément ce qui rend grep inutilisable à l'échelle. Les deux implémentations ci-dessous — Python et Node.js — émettent exactement cette structure sur la sortie standard, que Filebeat récupère ensuite.

Python : émettre chaque résolution en JSON

Le JSONFormatter sérialise chaque enregistrement et n'ajoute un champ que s'il est présent, ce qui garde les lignes compactes. La fonction solve_captcha journalise trois moments clés : la soumission, le succès (avec la latence mesurée) et l'échec ou le timeout.

import os
import json
import time
import logging
import sys
import requests

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


class JSONFormatter(logging.Formatter):
    def format(self, record):
        log_entry = {
            "timestamp": self.formatTime(record),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        # Add extra fields
        if hasattr(record, "captcha_id"):
            log_entry["captcha_id"] = record.captcha_id
        if hasattr(record, "captcha_type"):
            log_entry["captcha_type"] = record.captcha_type
        if hasattr(record, "solve_time"):
            log_entry["solve_time"] = record.solve_time
        if hasattr(record, "error_code"):
            log_entry["error_code"] = record.error_code
        if hasattr(record, "target_url"):
            log_entry["target_url"] = record.target_url
        if hasattr(record, "poll_count"):
            log_entry["poll_count"] = record.poll_count
        return json.dumps(log_entry)


# Configure logger
logger = logging.getLogger("captchaai")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JSONFormatter())
logger.addHandler(handler)

session = requests.Session()


def solve_captcha(sitekey, pageurl, captcha_type="recaptcha_v2"):
    extra = {"captcha_type": captcha_type, "target_url": pageurl}

    # Submit
    resp = session.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": 1
    })
    data = resp.json()

    if data.get("status") != 1:
        logger.error("Submit failed", extra={
            **extra, "error_code": data.get("request")
        })
        return {"error": data.get("request")}

    captcha_id = data["request"]
    extra["captcha_id"] = captcha_id
    logger.info("Task submitted", extra=extra)

    # Poll
    start = time.time()
    poll_count = 0
    for _ in range(60):
        time.sleep(5)
        poll_count += 1
        result = session.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "get", "id": captcha_id, "json": 1
        }).json()

        if result.get("status") == 1:
            elapsed = round(time.time() - start, 2)
            logger.info("Solve success", extra={
                **extra,
                "solve_time": elapsed,
                "poll_count": poll_count
            })
            return {"solution": result["request"]}

        if result.get("request") != "CAPCHA_NOT_READY":
            logger.error("Solve failed", extra={
                **extra,
                "error_code": result.get("request"),
                "poll_count": poll_count
            })
            return {"error": result.get("request")}

    logger.error("Solve timeout", extra={
        **extra,
        "error_code": "TIMEOUT",
        "poll_count": poll_count
    })
    return {"error": "TIMEOUT"}

Node.js : journalisation structurée

La version JavaScript suit la même logique avec une fonction log minimaliste. Le champ service permet de distinguer plusieurs workers dans un même index.

const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;

function log(level, message, fields = {}) {
  const entry = {
    timestamp: new Date().toISOString(),
    level,
    message,
    service: "captcha-worker",
    ...fields,
  };
  console.log(JSON.stringify(entry));
}

async function solveCaptcha(sitekey, pageurl, captchaType = "recaptcha_v2") {
  const fields = { captchaType, targetUrl: pageurl };

  const submitResp = await axios.post("https://ocr.captchaai.com/in.php", null, {
    params: {
      key: API_KEY, method: "userrecaptcha",
      googlekey: sitekey, pageurl, json: 1,
    },
  });

  if (submitResp.data.status !== 1) {
    log("error", "Submit failed", { ...fields, errorCode: submitResp.data.request });
    return { error: submitResp.data.request };
  }

  const captchaId = submitResp.data.request;
  fields.captchaId = captchaId;
  log("info", "Task submitted", fields);

  const startTime = Date.now();
  let pollCount = 0;

  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    pollCount++;

    const pollResp = await axios.get("https://ocr.captchaai.com/res.php", {
      params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
    });

    if (pollResp.data.status === 1) {
      const solveTime = ((Date.now() - startTime) / 1000).toFixed(2);
      log("info", "Solve success", { ...fields, solveTime: parseFloat(solveTime), pollCount });
      return { solution: pollResp.data.request };
    }

    if (pollResp.data.request !== "CAPCHA_NOT_READY") {
      log("error", "Solve failed", { ...fields, errorCode: pollResp.data.request, pollCount });
      return { error: pollResp.data.request };
    }
  }

  log("error", "Solve timeout", { ...fields, errorCode: "TIMEOUT", pollCount });
  return { error: "TIMEOUT" };
}

module.exports = { solveCaptcha };

Configuration de Filebeat

Filebeat surveille le répertoire des logs et transmet chaque ligne à Logstash. Comme les workers écrivent déjà du JSON, activez le décodage natif pour que les champs remontent à la racine du document plutôt que dans un blob message.

# filebeat.yml
filebeat.inputs:

  - type: log
    paths:

      - /var/log/captcha-worker/*.log
    json:
      keys_under_root: true
      add_error_key: true
      message_key: message

output.logstash:
  hosts: ["logstash:5044"]

Pipeline Logstash : parser et enrichir

Logstash parse le JSON, puis enrichit chaque événement. Ici, un champ calculé solve_time_bucket classe les résolutions en fast, medium (> 30 s) ou slow (> 90 s) — pratique pour segmenter les dashboards sans requête complexe. Le filtre date aligne l'horodatage du worker sur @timestamp, ce qui rend les graphes temporels fiables même en cas de retard d'ingestion.

# logstash-captcha.conf
input {
  beats {
    port => 5044
  }
}

filter {
  # Parse JSON logs
  json {
    source => "message"
    target => "captcha"
  }

  # Add computed fields
  if [captcha][solve_time] {
    mutate {
      add_field => {
        "solve_time_bucket" => "fast"
      }
    }
    if [captcha][solve_time] > 30 {
      mutate { update => { "solve_time_bucket" => "medium" } }
    }
    if [captcha][solve_time] > 90 {
      mutate { update => { "solve_time_bucket" => "slow" } }
    }
  }

  # Extract date
  date {
    match => ["[captcha][timestamp]", "ISO8601"]
    target => "@timestamp"
  }
}

output {
  elasticsearch {
    hosts => ["elasticsearch:9200"]
    index => "captcha-logs-%{+YYYY.MM.dd}"
  }
}

Modèle d'index Elasticsearch

Le mapping est décisif pour la performance. Déclarez captcha_type, error_code et target_url en keyword (filtrage exact et agrégations), solve_time en float et message en text pour la recherche plein texte. L'index journalier captcha-logs-AAAA.MM.JJ facilite ensuite la purge par ILM.

{
  "index_patterns": ["captcha-logs-*"],
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 0
    },
    "mappings": {
      "properties": {
        "captcha_type": { "type": "keyword" },
        "captcha_id": { "type": "keyword" },
        "error_code": { "type": "keyword" },
        "solve_time": { "type": "float" },
        "poll_count": { "type": "integer" },
        "target_url": { "type": "keyword" },
        "level": { "type": "keyword" },
        "message": { "type": "text" }
      }
    }
  }
}

Tableaux de bord Kibana

Six panneaux couvrent l'essentiel de l'exploitation quotidienne. Construisez-les une fois, puis épinglez-les dans un tableau de bord partagé avec l'équipe.

Panneau Visualisation Requête
Taux de réussite des résolutions Métrique level:info AND message:"Solve success" / total
Répartition des erreurs Diagramme circulaire level:error regroupé par error_code
Latence au fil du temps Graphique linéaire solve_time moyen dans le temps
Erreurs au fil du temps Graphique à barres Nombre de level:error par tranche de 5 minutes
Résolutions les plus lentes Tableau de données Top 10 par solve_time décroissant
Activité de la file d'attente Graphique en aires Décompte par message ("Task submitted" vs "Solve success")

Requêtes KQL utiles

Gardez ces requêtes sous la main pour les investigations ad hoc dans Discover.

# All errors in the last hour
level:error AND @timestamp:[now-1h TO now]

# Timeout errors for reCAPTCHA
error_code:TIMEOUT AND captcha_type:recaptcha_v2

# Slow solves (> 60 seconds)
solve_time:>60

# Errors for a specific target URL
level:error AND target_url:"example.com"

# Specific CAPTCHA ID investigation
captcha_id:"73519847"

Conservation des logs et RGPD

Les logs de résolution peuvent contenir des données à caractère personnel — typiquement dans target_url, quand l'URL embarque un identifiant utilisateur ou un e-mail. Appliquez le principe de minimisation : ne journalisez que les métadonnées nécessaires au diagnostic (identifiant, type, latence, statut), tronquez ou hachez les paramètres sensibles de l'URL, et ne conservez jamais le token de résolution. Côté durée, 30 jours couvrent l'exploitation courante ; au-delà, l'analyse de tendance se satisfait d'agrégats sur 90 jours. Documentez ce choix dans votre registre de traitements et automatisez la suppression avec Elasticsearch ILM plutôt que de purger les index à la main.

Dépannage

Problème Cause Correctif
Les logs n'apparaissent pas dans Kibana Filebeat ne transmet rien Vérifiez les logs de Filebeat et la correspondance du motif de chemin
Erreurs de parsing JSON Lignes non-JSON dans le fichier Activez json.keys_under_root dans Filebeat et corrigez la sortie du logger
Trop d'index Index journalier sans ILM Configurez la gestion du cycle de vie des index (ILM) avec 30 jours de rétention
Requêtes lentes Mapping keyword manquant Utilisez le type keyword pour les champs filtrables, pas text

FAQ

Faut-il journaliser le contenu du token de résolution ?

Non. Le token est à usage unique et n'a aucune valeur diagnostique une fois consommé. Le stocker gonfle l'index et crée un risque de sécurité inutile : ne conservez que les métadonnées (identifiant, type, latence, statut).

Quelle durée de conservation choisir pour rester conforme au RGPD ?

Trente jours pour les logs opérationnels, quatre-vingt-dix jours si vous faites de l'analyse de tendance. Appliquez la minimisation des données, documentez la durée dans votre registre de traitements et laissez Elasticsearch ILM supprimer automatiquement les index expirés.

Comment configurer une alerte Kibana sur un pic d'erreurs ?

Créez une règle dans Kibana Alerting basée sur une requête level:error, avec un seuil (par exemple plus de 20 erreurs sur 5 minutes) et un connecteur Slack ou e-mail. Le champ error_code permet de router chaque alerte selon le type d'échec.

Peut-on envoyer les logs directement à Elasticsearch sans Logstash ?

Oui, via le module Elasticsearch de Filebeat, ce qui suffit pour un pipeline simple. Mais dès que vous ajoutez des champs calculés comme solve_time_bucket ou que vous devez normaliser plusieurs formats de logs, Logstash reste l'étage d'enrichissement le plus souple à maintenir.

Comment suivre la latence P90 par type de CAPTCHA ?

Ajoutez une agrégation de percentiles sur solve_time dans Kibana, segmentée par captcha_type. Le champ solve_time_bucket calculé dans Logstash permet en plus de filtrer rapidement les résolutions lentes (> 90 s).

Prochaines étapes

Vous avez la chaîne complète : logs JSON côté worker, ingestion Filebeat, enrichissement Logstash, index Elasticsearch et dashboards Kibana. Pour la mettre en place, récupérez votre clé API CaptchaAI et instrumentez votre premier worker.

Guides associés :

Les commentaires sont désactivés pour cet article.