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: