DevOps y Escalado

Trazas de OpenTelemetry para pipelines de resolución de CAPTCHA

Cuando un CAPTCHA tarda 40 segundos en resolverse, ¿el tiempo se fue en la red, en el envío a la API o en el sondeo del resultado? Sin trazas no lo sabes: tu pipeline de resolución de CAPTCHA es una caja negra. OpenTelemetry (OTel) rompe esa caja con trazas distribuidas neutrales respecto al proveedor. Instrumentas el flujo una sola vez y exportas los datos a Jaeger, Zipkin, Datadog o cualquier backend compatible con OTel para ver, span por span, dónde se va cada segundo.

La estructura de una traza de resolución de CAPTCHA

Una traza es un árbol de spans: un span padre que envuelve toda la operación y spans hijos que miden cada fase. Para un flujo de resolución, el envío a in.php y el sondeo repetido a res.php son fases distintas, así que cada una merece su propio span. Así se ve el árbol completo, desde el scraping de la página hasta la inyección del token:

[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

Con esta jerarquía, un solo vistazo te dice si el cuello de botella está en la latencia del envío, en el número de sondeos o en el tiempo que el servicio tarda en devolver la solución.

Python: instrumentar el pipeline con OpenTelemetry

Instalar las dependencias

Necesitas el SDK de OTel, el exportador OTLP y la instrumentación automática de requests:

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

El código instrumentado

El patrón es directo: un span captcha.solve como padre, y dentro dos spans hijos, captcha.submit y captcha.poll. Cada intento de sondeo abre su propio span, y en cada fase registras atributos (tipo de CAPTCHA, ID, duración) y el estado final con set_status. El auto-instrumentado de requests añade además los spans HTTP de bajo nivel sin que toques nada:

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"}

Node.js: la misma instrumentación en JavaScript

Instalar los paquetes

Si tu backend corre en Node.js, el SDK de OTel para Node y la instrumentación de HTTP cubren el mismo terreno:

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

El código instrumentado

startActiveSpan propaga el contexto de forma automática, así que los spans hijos se cuelgan del padre sin que tengas que pasarlo a mano. Fíjate en el finally de cada bloque: cerrar el span ahí es lo que evita spans huérfanos cuando algo lanza una excepción a mitad del sondeo:

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 };

Configurar el OTel Collector

En lugar de que cada servicio hable directo con Jaeger o Datadog, lo habitual es enviar todo al OTel Collector y que él reenvíe. Así cambias de backend editando una sola configuración, no el código de la aplicación:

# 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]

Qué atributos verás en cada traza

Una vez que las trazas llegan a tu backend, estos son los atributos que de verdad usarás para diagnosticar. Piensa en una agencia que monitorea precios en marketplaces regionales tipo MercadoLibre o Amazon.es: agrupando por captcha.type descubre al instante qué tipo de desafío le está costando más tiempo en cada campaña.

Atributo del span Valor Qué te dice
captcha.type recaptcha_v2 Qué tipos de CAPTCHA tardan más
captcha.solve_time_s 24.5 Latencia real de resolución
captcha.poll.count 5 Cuántos sondeos hicieron falta
captcha.error ERROR_WRONG_CAPTCHA_ID Desglose por tipo de error
captcha.id 73519... Rastrear intentos de resolución concretos

Problemas habituales y cómo resolverlos

Casi todos los fallos de instrumentación caen en cuatro categorías. Esta tabla es tu primer punto de revisión cuando algo no cuadra:

Problema Causa Solución
No aparecen trazas El OTel Collector no está corriendo Revisa docker ps y verifica la URL del endpoint
Faltan spans hijos El span no se cerró bien Cierra siempre con span.end() en el bloque finally
Trazas fragmentadas El contexto no se propagó Usa startActiveSpan para propagar el contexto automáticamente
Aviso de alta cardinalidad Demasiados valores únicos de atributo No uses captcha.id como etiqueta en las métricas

Muestreo en producción: cuánto trazar sin disparar el coste

En desarrollo puedes trazar el 100% de las resoluciones y estudiar cada span con calma. En producción, guardar cada traza infla el almacenamiento sin darte más información útil: el patrón estándar es muestrear un porcentaje (por ejemplo, el 10%) y quedarte con una foto estadística fiable del pipeline. La única excepción es clara: traza siempre el 100% de los errores, porque son los eventos que necesitas investigar uno por uno.

Este equilibrio importa especialmente si facturas en USD con un presupuesto ajustado, como muchas agencias y freelancers de la región. El coste de CaptchaAI depende de tus threads concurrentes, no del volumen de resoluciones; el coste de observabilidad, en cambio, crece con las trazas que retienes. Muestrear te deja ambos bajo control sin quedarte a ciegas.

Preguntas frecuentes

¿Puedo cambiar de Jaeger a Datadog sin tocar el código de la aplicación?

Sí. Como exportas al OTel Collector vía OTLP, el destino final se define en la configuración del Collector, no en tu código. Cambiar de Jaeger a Datadog, New Relic o Zipkin es editar el bloque exporters del YAML y reiniciar el Collector.

¿Cómo separo el tiempo de envío del tiempo de sondeo?

Compara la duración de los spans captcha.submit y captcha.poll dentro de la misma traza. Si captcha.submit es rápido pero captcha.poll acumula muchos intentos, el cuello de botella es el tiempo de resolución del servicio, no tu red ni tu código.

¿Por qué salta el aviso de "alta cardinalidad" y cómo lo evito?

Aparece cuando usas valores muy variables —como captcha.id— en las etiquetas de tus métricas, lo que genera series casi infinitas. Deja captcha.id a nivel de span (para rastrear casos concretos) y en las métricas agrupa solo por dimensiones estables como captcha.type.

¿Estas trazas me ayudan a elegir el plan de CaptchaAI?

Sí, de forma indirecta. Midiendo la concurrencia real de tu pipeline sabes cuántos threads necesitas de verdad: si rara vez tienes más de cinco resoluciones en vuelo a la vez, el plan BASIC ($15/mes, 5 threads) sobra; si observas picos sostenidos, ADVANCE ($90/mes, 50 threads) evita que las tareas se encolen.

¿El seguimiento ralentiza el pipeline?

Prácticamente nada. OTel exporta por lotes de forma asíncrona y muestrea, así que un span añade microsegundos. Frente a una resolución de CAPTCHA que dura entre 5 y 120 segundos, el coste del rastreo es inapreciable.

Próximos pasos

Convierte tu pipeline de CAPTCHA en algo observable: consigue tu API key de CaptchaAI y añade la instrumentación de OpenTelemetry con el código de arriba.

Guías relacionadas:

Los comentarios están deshabilitados para este artículo.