عندما يقفز زمن حل CAPTCHA من ثماني ثوانٍ إلى ثلاثين، يصبح السؤال: أين ذهب الوقت؟ في إرسال الطلب أم في الاستطلاع الدوري أم في زمن وصول الشبكة؟ يجيب التتبّع الموزّع عن ذلك برقم لا بتخمين. يُجهّز OpenTelemetry (OTel) خط أنابيب الحل مرّة واحدة ويُصدّر الآثار (Traces) إلى Jaeger أو Zipkin أو Datadog أو أي واجهة متوافقة مع OTel، فتحصل على صورة دقيقة لكل مرحلة من مراحل الحل على CaptchaAI.
لماذا يحتاج خط حل CAPTCHA إلى تتبّع موزّع؟
عملية حل CAPTCHA صندوق أسود بطبيعتها: زمنها يتراوح بين خمس ثوانٍ ومئة وعشرين ثانية للمهمة الواحدة، ويتغيّر بحسب نوع الاختبار وحِمل الخدمة وجودة الشبكة. من دون تتبّع، كل ما تراه رقم إجمالي واحد لا يخبرك أين ضاع الوقت. يفكّك التتبّع الموزّع هذا الرقم إلى مراحل قابلة للقياس كلٌّ على حدة:
- مرحلة الإرسال: كم استغرق إنشاء المهمة على in.php والحصول على معرّفها.
- مرحلة الاستطلاع: كم محاولة استطلاع لزمت حتى جهزت النتيجة، وكم استهلكت من زمن.
- زمن الحل الفعلي: الفارق بين لحظة الإرسال ولحظة جهوز التوكن، معزولًا عن زمن الشبكة.
هكذا يتحوّل "الحل بطيء اليوم" من انطباع غامض إلى امتداد محدّد في الأثر يمكن فتحه وقياسه ومقارنته عبر الزمن.
بنية شجرة التتبّع
يتكوّن كل طلب حل من امتداد أب (span) تتفرّع منه امتدادات لكل مرحلة، كما يلي:
[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
تفعيل OpenTelemetry في Python
غلّف مرحلتي الإرسال والاستطلاع بامتدادات تحمل سمات الحل.
التثبيت
pip install opentelemetry-api opentelemetry-sdk \
opentelemetry-exporter-otlp \
opentelemetry-instrumentation-requests
التنفيذ
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"}
تفعيل OpenTelemetry في JavaScript
في Node.js يمرّر startActiveSpan السياق تلقائيًا بين الامتدادات.
التثبيت
npm install @opentelemetry/api @opentelemetry/sdk-node \
@opentelemetry/sdk-trace-node \
@opentelemetry/exporter-trace-otlp-grpc \
@opentelemetry/instrumentation-http
التنفيذ
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 };
إعداد مُجمِّع OTel Collector
يستقبل المُجمِّع الآثار عبر OTLP ويُصدّرها إلى Jaeger، ويمكن تبديلها إلى Datadog بسطر واحد:
# 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]
ماذا تكشف لك الآثار
بعد وصول الآثار تصبح كل عملية حل قابلة للقياس والتصفية. أهم السمات:
| سمة الامتداد | مثال للقيمة | ما الذي تكشفه |
|---|---|---|
captcha.type |
recaptcha_v2 |
أي أنواع CAPTCHA تستغرق وقتًا أطول |
captcha.solve_time_s |
24.5 |
زمن الحل الفعلي بالثواني |
captcha.poll.count |
5 |
عدد مرّات الاستطلاع اللازمة |
captcha.error |
ERROR_WRONG_CAPTCHA_ID |
تصنيف نوع الخطأ عند الفشل |
captcha.id |
73519... |
تتبّع محاولة حل بعينها |
ضبط أخذ العينات في الإنتاج
التتبّع نفسه يكاد لا يضيف حِملًا يُذكر: يعتمد OpenTelemetry على تصدير دُفعي غير متزامن عبر BatchSpanProcessor، فلا ينتظر خط الحل اكتمال التصدير، ويبقى ما يضيفه الامتداد الواحد في حدود ميكروثوانٍ لا تُقاس أمام ثوانٍ الحل. التحدّي الحقيقي ليس الأداء بل تكلفة التخزين حين تُتَتبَّع كل عملية حل في الإنتاج. القاعدة العملية بسيطة:
- في التطوير: تتبّع بنسبة 100% لرؤية كل تفصيل أثناء بناء التكامل واختباره.
- في الإنتاج: اخفض المعدل إلى نسبة تمثيلية — مثلًا 10% — للحفاظ على رؤية إحصائية بتكلفة أقل.
- الأخطاء دائمًا: احتفظ بنسبة 100% للآثار التي انتهت بحالة خطأ، فهي الأثمن حين تشخّص الأعطال.
بهذا توازن بين وضوح الرؤية وتكلفة التخزين دون أن تفقد أثر أي عملية حل فاشلة.
مثال تطبيقي: تشخيص ارتفاع زمن الحل
لنفترض أن فريقًا يشغّل منصّة حجوزات في منطقة الخليج على خطة ADVANCE ($90 شهريًا، 50 thread)، ويلاحظ ارتفاع زمن الحل في ساعات الذروة. بدل التخمين، يفتح أثرًا في Jaeger فيجد أن امتداد captcha.poll سجّل ثماني محاولات بدل ثلاث بينما بقي captcha.submit سريعًا. الاستنتاج مباشر: التأخير مصدره وقت الحل لا الشبكة، والمعالجة بتوزيع الحمل على threads أكثر أو بمراجعة نوع CAPTCHA الأبطأ. ولأن الأثر يحمل سمة captcha.type، يستطيع الفريق أيضًا تصفية الحوادث حسب نوع الاختبار ليعرف إن كان البطء محصورًا في نوع واحد مثل reCAPTCHA v2 أم عامًّا عبر كل الأنواع. هكذا يحوّل التتبّع الأسئلة الغامضة إلى أرقام قابلة للتصرّف.
تشخيص المشكلات الشائعة
| المشكلة | السبب المحتمل | الحل |
|---|---|---|
| لا تظهر أي آثار | مُجمِّع OTel Collector متوقّف أو عنوان النقطة خاطئ | تحقّق من تشغيل الحاوية عبر docker ps ومن صحّة OTEL_EXPORTER_OTLP_ENDPOINT |
| امتدادات أبناء ناقصة | لم يُغلق الامتداد بشكل سليم | أغلق كل امتداد داخل كتلة finally عبر span.end() |
| الأثر مُجزّأ وغير مترابط | لم يُمرَّر السياق (Context) بين الامتدادات | استخدم start_as_current_span أو startActiveSpan لتمرير السياق تلقائيًا |
| تحذير من ارتفاع التبّاين (cardinality) | استُخدمت قيم فريدة كثيرة كسمات في المقاييس | لا تستخدم captcha.id كوسم في المقاييس |
الأسئلة الشائعة
كيف أُميّز بين تأخّر الشبكة وتأخّر الحل داخل الأثر؟
قارن مدّة captcha.submit بـcaptcha.poll في الأثر: إن كان الإرسال بطيئًا فالمشكلة في الشبكة أو المصادقة، وإن كان الاستطلاع الأطول فالوقت يُقضى في انتظار الحل. وتعطيك سمة captcha.solve_time_s الرقم النهائي.
هل يمكنني ربط أثر الحل بأثر عملية استخراج البيانات الأوسع؟
نعم. أنشئ امتداد captcha.solve داخل امتداد أب لعملية الاستخراج، فيُمرَّر السياق تلقائيًا وتظهر عملية الحل ضمن الأثر الأوسع، كاشفةً نسبة الوقت التي يستهلكها الحل من إجمالي الطلب.
هل يعمل هذا التتبّع مع أنواع CAPTCHA الأخرى؟
نعم. النمط نفسه ينطبق على كل نوع يدعمه CaptchaAI — reCAPTCHA v2/v3، وCloudflare Turnstile، وGeeTest v3، وصور OCR والشبكة — بتغيير قيمة method وسمة captcha.type فقط.
هل أحتاج إلى مُجمِّع OTel Collector أم أُصدّر مباشرة إلى Jaeger؟
كلاهما ممكن. يقبل OTLPSpanExporter التصدير مباشرة إلى Jaeger أو Zipkin أثناء التطوير، لكن إدخال مُجمِّع OTel Collector بين خط الحل والواجهة الخلفية يمنحك طبقة تجميع ودُفعات وتوجيهًا مرنًا: تبدّل الوجهة من Jaeger إلى Datadog بتعديل سطر واحد في ملف الإعداد دون لمس كود الحل. لهذا يُفضّل المُجمِّع في الإنتاج.
الخطوات التالية
- ابدأ السريع مع CaptchaAI: أول عملية حل خلال خمس دقائق
- حلّ reCAPTCHA v2 عبر الـ API خطوة بخطوة
- حلّ Cloudflare Turnstile عبر الـ API
- حلّ GeeTest v3 عبر الـ API