DevOps & Scaling

Traçage OpenTelemetry pour les pipelines de résolution de CAPTCHA

Quand un pipeline ralentit, la vraie question n'est jamais « est-ce le CAPTCHA ? » mais « quelle phase du CAPTCHA ? ». Une trace OpenTelemetry (OTel) y répond en un coup d'œil : un span parent couvre la résolution complète, un span enfant l'envoi de la tâche, un span par interrogation du résultat. Vous instrumentez une seule fois, puis vous exportez vers Jaeger, Zipkin, Datadog ou n'importe quel backend compatible OTel, sans retoucher le code le jour où vous changez de plateforme d'observabilité.

Au programme : instrumentation Python et Node.js de l'API CaptchaAI, collecteur OTel, attributs de span utiles, échantillonnage et dépannage.

Anatomie d'une trace de résolution CAPTCHA

[Scrape Page]
  └── [Solve CAPTCHA]                    ← Parent span
        ├── [Submit Task]                ← HTTP POST to in.php
        ├── [Poll Result]               ← Repeated GET to res.php
        │     ├── [Poll Attempt 1]       ← CAPCHA_NOT_READY
        │     ├── [Poll Attempt 2]       ← CAPCHA_NOT_READY
        │     └── [Poll Attempt 3]       ← OK (solution)
        └── [Apply Token]               ← Inject into form

Le span parent captcha.solve encadre toute l'opération et chaque phase devient un enfant. Cette hiérarchie tranche la plupart des débats d'équipe : si captcha.submit répond en 200 ms et que captcha.poll dure 40 s, le réseau n'est pas en cause, c'est le temps de résolution du type concerné. Si le span parent démarre plusieurs secondes après l'entrée en file d'attente, le goulot est côté capacité : vos threads sont tous occupés.

Les attributs de span qui servent vraiment

Attribut de span Exemple Ce qu'il vous apprend
captcha.type recaptcha_v2 Quels types pèsent le plus sur la latence
captcha.solve_time_s 24.5 Le temps de résolution réellement observé
captcha.poll.count 5 Combien d'interrogations avant d'obtenir la solution
captcha.error ERROR_WRONG_CAPTCHA_ID La répartition des codes d'erreur
captcha.id 73519... Retrouver une tentative précise dans les logs

Deux règles de discipline avant d'instrumenter :

  • Cardinalité : captcha.id est parfait sur un span, catastrophique comme label de métrique — chaque valeur unique crée une série temporelle.
  • RGPD : captcha.target_url embarque souvent une query string avec un identifiant de session ou une adresse e-mail. Tronquez l'URL au chemin, et fixez une rétention courte.

Python : tracer l'envoi et le polling avec OpenTelemetry

Installer les paquets

pip install opentelemetry-api opentelemetry-sdk \
    opentelemetry-exporter-otlp \
    opentelemetry-instrumentation-requests

Le code instrumenté

import os
import time
import requests
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
    OTLPSpanExporter,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.trace import StatusCode

# Configure provider
resource = Resource.create({"service.name": "captcha-pipeline"})
provider = TracerProvider(resource=resource)

# Export to OTel Collector (or Jaeger/Zipkin directly)
exporter = OTLPSpanExporter(
    endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT",
                            "http://localhost:4317")
)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)

# Auto-instrument requests library
RequestsInstrumentor().instrument()

tracer = trace.get_tracer("captchaai.solver")
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
session = requests.Session()


def solve_captcha(sitekey, pageurl, captcha_type="recaptcha_v2"):
    """Solve a CAPTCHA with full OpenTelemetry tracing."""
    with tracer.start_as_current_span(
        "captcha.solve",
        attributes={
            "captcha.type": captcha_type,
            "captcha.target_url": pageurl,
        }
    ) as solve_span:

        # Submit phase
        with tracer.start_as_current_span("captcha.submit") as submit_span:
            resp = session.post("https://ocr.captchaai.com/in.php", data={
                "key": API_KEY,
                "method": "userrecaptcha",
                "googlekey": sitekey,
                "pageurl": pageurl,
                "json": 1
            })
            data = resp.json()
            submit_span.set_attribute("http.status_code", resp.status_code)

            if data.get("status") != 1:
                error = data.get("request", "UNKNOWN")
                submit_span.set_status(StatusCode.ERROR, error)
                submit_span.set_attribute("captcha.error", error)
                solve_span.set_status(StatusCode.ERROR, error)
                return {"error": error}

            captcha_id = data["request"]
            submit_span.set_attribute("captcha.id", captcha_id)
            solve_span.set_attribute("captcha.id", captcha_id)

        # Poll phase
        with tracer.start_as_current_span("captcha.poll") as poll_span:
            poll_count = 0
            poll_start = time.time()

            for _ in range(60):
                time.sleep(5)
                poll_count += 1

                with tracer.start_as_current_span(
                    f"captcha.poll.attempt",
                    attributes={"captcha.poll.number": poll_count}
                ) as attempt_span:
                    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:
                        attempt_span.set_attribute("captcha.poll.ready", True)
                        elapsed = time.time() - poll_start
                        poll_span.set_attribute("captcha.poll.count", poll_count)
                        poll_span.set_attribute(
                            "captcha.poll.duration_s", round(elapsed, 2)
                        )
                        solve_span.set_attribute(
                            "captcha.solve_time_s", round(elapsed, 2)
                        )
                        solve_span.set_status(StatusCode.OK)
                        return {
                            "solution": result["request"],
                            "elapsed": elapsed,
                            "polls": poll_count
                        }

                    if result.get("request") != "CAPCHA_NOT_READY":
                        error = result.get("request", "UNKNOWN")
                        attempt_span.set_status(StatusCode.ERROR, error)
                        poll_span.set_status(StatusCode.ERROR, error)
                        solve_span.set_status(StatusCode.ERROR, error)
                        return {"error": error}

                    attempt_span.set_attribute("captcha.poll.ready", False)

            poll_span.set_attribute("captcha.poll.count", poll_count)
            poll_span.set_status(StatusCode.ERROR, "TIMEOUT")
            solve_span.set_status(StatusCode.ERROR, "TIMEOUT")
            return {"error": "TIMEOUT"}

Trois détails font la différence :

  • RequestsInstrumentor().instrument() trace les appels HTTP vers in.php et res.php : la latence réseau arrive gratuitement.
  • Le statut d'erreur remonte du span enfant vers le span parent, faute de quoi une trace en échec ressort en vert dans Jaeger.
  • La boucle borne l'attente à 60 tentatives et marque explicitement TIMEOUT : un span terminé sans statut est indiscernable d'un succès.

Node.js : la même trace OpenTelemetry en JavaScript

Installer les paquets

npm install @opentelemetry/api @opentelemetry/sdk-node \
    @opentelemetry/sdk-trace-node \
    @opentelemetry/exporter-trace-otlp-grpc \
    @opentelemetry/instrumentation-http

L'implémentation

const { NodeSDK } = require("@opentelemetry/sdk-node");
const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-grpc");
const { HttpInstrumentation } = require("@opentelemetry/instrumentation-http");
const { trace, SpanStatusCode } = require("@opentelemetry/api");
const axios = require("axios");

// Initialize SDK
const sdk = new NodeSDK({
  serviceName: "captcha-pipeline",
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4317",
  }),
  instrumentations: [new HttpInstrumentation()],
});
sdk.start();

const tracer = trace.getTracer("captchaai.solver");
const API_KEY = process.env.CAPTCHAAI_API_KEY;

async function solveCaptchaWithTracing(sitekey, pageurl, captchaType = "recaptcha_v2") {
  return tracer.startActiveSpan("captcha.solve", {
    attributes: { "captcha.type": captchaType, "captcha.target_url": pageurl },
  }, async (solveSpan) => {
    try {
      // Submit
      const captchaId = await tracer.startActiveSpan(
        "captcha.submit",
        async (submitSpan) => {
          try {
            const resp = await axios.post("https://ocr.captchaai.com/in.php", null, {
              params: {
                key: API_KEY, method: "userrecaptcha",
                googlekey: sitekey, pageurl, json: 1,
              },
            });

            if (resp.data.status !== 1) {
              submitSpan.setStatus({ code: SpanStatusCode.ERROR, message: resp.data.request });
              throw new Error(resp.data.request);
            }

            submitSpan.setAttribute("captcha.id", resp.data.request);
            return resp.data.request;
          } finally {
            submitSpan.end();
          }
        }
      );

      solveSpan.setAttribute("captcha.id", captchaId);

      // Poll
      return await tracer.startActiveSpan("captcha.poll", async (pollSpan) => {
        try {
          let pollCount = 0;
          const pollStart = Date.now();

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

            const result = await tracer.startActiveSpan(
              "captcha.poll.attempt",
              { attributes: { "captcha.poll.number": pollCount } },
              async (attemptSpan) => {
                try {
                  const resp = await axios.get("https://ocr.captchaai.com/res.php", {
                    params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
                  });
                  attemptSpan.setAttribute("captcha.poll.ready", resp.data.status === 1);
                  return resp.data;
                } finally {
                  attemptSpan.end();
                }
              }
            );

            if (result.status === 1) {
              const elapsed = (Date.now() - pollStart) / 1000;
              pollSpan.setAttribute("captcha.poll.count", pollCount);
              solveSpan.setAttribute("captcha.solve_time_s", elapsed);
              solveSpan.setStatus({ code: SpanStatusCode.OK });
              return { solution: result.request, elapsed, polls: pollCount };
            }

            if (result.request !== "CAPCHA_NOT_READY") {
              throw new Error(result.request);
            }
          }
          throw new Error("TIMEOUT");
        } catch (err) {
          pollSpan.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
          throw err;
        } finally {
          pollSpan.end();
        }
      });
    } catch (err) {
      solveSpan.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      return { error: err.message };
    } finally {
      solveSpan.end();
    }
  });
}

module.exports = { solveCaptchaWithTracing };

startActiveSpan propage le contexte automatiquement : les spans d'interrogation se rattachent au bon parent même à l'intérieur d'un await. Le finally qui appelle span.end() n'a rien de cosmétique — un span jamais terminé disparaît de la trace, et vous passez ensuite une heure à chercher un enfant qui n'a jamais été exporté.

Un collecteur OTel comme point de sortie unique

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:
    timeout: 5s

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true
  # Or export to Datadog, New Relic, etc.

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger]

Faites pointer vos workers vers un collecteur unique plutôt que directement vers le backend d'observabilité : vous changez de destination — Jaeger en local, Datadog ou Grafana Tempo en production — en modifiant un fichier YAML. Sur une flotte hébergée chez OVHcloud ou Scaleway, le schéma habituel est un collecteur par région, à côté des workers : les spans partent en gRPC sur le réseau privé et une seule sortie franchit l'Internet public. Si vos workers tournent sur eu-west-3 (Paris) et votre backend aux États-Unis, mesurez cette latence d'export.

Échantillonnage, coût de stockage et capacité en threads

Tracer chaque résolution est la bonne pratique en développement. En production, un pipeline qui traite des dizaines de milliers de résolutions par jour produit un volume de traces plus coûteux à stocker qu'à générer. Le réglage courant : environ 10 % des traces réussies échantillonnées, la totalité des traces en erreur conservée.

Les durées de span racontent aussi votre dimensionnement. La facturation CaptchaAI se fait au thread concurrent, avec un nombre de résolutions illimité par thread : BASIC ($15/mois, 5 threads), ADVANCE ($90/mois, 50 threads). Si captcha.solve garde une durée stable mais que le délai d'attente avant le span s'allonge aux heures de pointe, vous n'avez pas un problème de résolution, vous avez un problème de threads. Comparez le nombre de spans captcha.solve simultanés à votre plafond de threads avant de toucher au reste du pipeline.

Un cas concret : une équipe QA lyonnaise exécute ses tests de formulaires sur deux régions, Paris et Montréal. Les traces montrent des temps de résolution comparables, mais une latence d'envoi nettement supérieure depuis Montréal. Le correctif n'est pas de changer de plan : gardez l'appel API et le worker dans la même zone, et laissez le collecteur régional absorber l'export.

Dépannage

Problème Cause probable Correctif
Aucune trace n'arrive Le collecteur OTel n'est pas démarré Vérifiez docker ps, puis l'URL de l'endpoint OTLP
Des spans enfants manquent Un span n'a jamais été terminé Appelez toujours span.end() dans un bloc finally
Traces fragmentées en plusieurs arbres Le contexte n'est pas propagé Passez par startActiveSpan ou le context manager Python
Alerte de cardinalité côté backend Trop de valeurs d'attribut uniques N'utilisez jamais captcha.id comme label de métrique
Traces exportées mais introuvables Échantillonneur trop agressif Vérifiez OTEL_TRACES_SAMPLER avant de suspecter le code

FAQ

Traces ou métriques : que faut-il pour un pipeline de résolution ?

Les deux, pour des questions différentes. Les métriques répondent à « le taux de réussite a-t-il baissé cette nuit ? », les traces à « pourquoi cette résolution-là a pris 90 s ». Commencez par les métriques, ajoutez le traçage quand il faut expliquer un cas individuel.

Comment relier une trace à une tâche CaptchaAI ?

Posez l'identifiant renvoyé par in.php comme attribut captcha.id, sur le span d'envoi et sur le span parent. Une ligne de log applicative mène alors à la trace complète, et inversement, sans corrélation manuelle.

Les attributs de span peuvent-ils contenir des données personnelles ?

Ils le peuvent, et c'est précisément le risque. Une URL cible complète transporte souvent un identifiant de session : tronquez-la, n'ajoutez jamais d'adresse e-mail ni de contenu de formulaire, et alignez la rétention de vos traces sur vos obligations RGPD.

Combien de temps conserver les traces ?

Sept à quatorze jours suffisent à la plupart des équipes : au-delà, la tendance se lit dans les métriques agrégées. Gardez plus longtemps un échantillon de traces en erreur si vous suivez une régression.

Faut-il un backend payant pour démarrer ?

Non. Un Jaeger en conteneur suffit à instrumenter le pipeline, valider la hiérarchie des spans et mesurer vos temps de résolution. Le backend commercial peut attendre que le volume le justifie.

Prochaines étapes

Instrumentez le pipeline une fois et chaque incident devient une trace lisible plutôt qu'une hypothèse. Créez votre clé API CaptchaAI, branchez l'exportateur OTLP sur votre collecteur et regardez arriver vos premiers spans.

Guides associés :

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