DevOps & Scaling

OpenTelemetry Tracing для конвейеров решения CAPTCHA

Конвейер завис на 40 секунд — но где: отправка, опрос res.php или сеть до коллектора? Без трассировки это гадание по логам. OpenTelemetry делит решение на span'ы submit → poll → apply и показывает вклад каждого шага, а данные можно экспортировать в Jaeger, Zipkin, Datadog или любой OTel-совместимый бэкенд.

Логи фиксируют факт ошибки, но не показывают, где именно ушло время между вызовами. Трассировка превращает один непрозрачный вызов solve_captcha() в дерево span'ов с точными длительностями — это разница между «где-то тормозит бэкенд» и «60 % времени уходит на третью попытку опроса res.php».

Что теряется без трассировки

Без span'ов инженер обычно видит только итоговое время выполнения и одну строку в логе с кодом ошибки. На практике это означает:

  • сложно понять, растёт ли задержка на стороне сети, коллектора или самого решения CAPTCHA;
  • ошибки ERROR_WRONG_CAPTCHA_ID и TIMEOUT смешиваются в одном счётчике без привязки к конкретному шагу;
  • при разборе инцидента приходится вручную сопоставлять временные метки из разных логов и сервисов.

Дерево span'ов ниже закрывает все три пункта — у каждого шага есть своя длительность, статус и набор атрибутов, которые можно фильтровать и агрегировать в бэкенде трассировки.

Дерево span'ов одного решения

captcha.solve разворачивается в дерево 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

Каждый дочерний span наследует контекст родителя captcha.solve, поэтому в Jaeger дерево остаётся связным даже при нескольких повторных попытках опроса.

Python: инструментируем пайплайн решения CAPTCHA

Установка зависимостей

Три пакета покрывают SDK, gRPC-экспортёр и авто-инструментирование requests — этого достаточно, чтобы вызовы к in.php и res.php попадали в трассировку без ручной разметки каждого запроса.

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

Код инструментирования

submit и poll — отдельные span'ы с captcha.id и кодом ошибки:

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

Итоговая функция возвращает либо решение с elapsed/polls, либо структуру с error — в обоих случаях статус span'а уже проставлен, и в Jaeger сразу видно, на каком шаге пайплайн остановился.

Node.js: тот же подход на JavaScript

Установка зависимостей

Пакеты дают SDK для Node.js, gRPC-экспортёр OTLP и авто-инструментирование HTTP-запросов — набор аналогичен Python-версии, только без requests.

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

Код инструментирования

Та же схема: submit, poll со вложенными попытками:

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

Структура та же, что в Python: captcha.solve — родительский span, captcha.submit и captcha.poll — дочерние, captcha.poll.attempt — под-span на каждую отдельную попытку опроса.

Настройка OTel Collector

Коллектор принимает span'ы по OTLP/gRPC — здесь в 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]

Собственный коллектор не обязателен для одного бэкенда — SDK умеет экспортировать в Jaeger напрямую. Он нужен, когда трассировки одновременно уходят в несколько систем или когда пайплайн и бэкенд трассировки развёрнуты в разных регионах.

Что покажут атрибуты трассировки

Эти атрибуты стоит вывести на дашборд в первую очередь — они отвечают на вопрос «что именно тормозит», не заставляя открывать каждый span вручную:

Атрибут span'а Значение Что показывает
captcha.type recaptcha_v2 Самые медленные типы
captcha.solve_time_s 24.5 Задержка решения
captcha.poll.count 5 Число опросов
captcha.error ERROR_WRONG_CAPTCHA_ID Разбивка ошибок
captcha.id 73519... ID попытки

Типичные проблемы

Большинство проблем с трассировкой CAPTCHA-пайплайна сводится к четырём причинам:

Проблема Причина Исправление
Нет трассировок Collector не запущен Проверьте docker ps и URL
Пропадают span'ы Span не завершён span.end() в finally
Трассировка рвётся Контекст не передаётся Используйте startActiveSpan
Кардинальность высокая Слишком много значений Не тегируйте метрики captcha.id

Частые вопросы

OTel Collector обязателен, или можно экспортировать сразу в Jaeger?

Необязательно — для теста хватит экспортера Jaeger. Collector нужен при нескольких бэкендах или когда пайплайн стоит в другом регионе (Франкфурт, Казахстан), а бэкенд — в третьем.

Сколько накладных расходов добавляет трассировка при высоком темпе задач?

Немного: экспорт асинхронный, пакетами (BatchSpanProcessor), и не блокирует основной поток выполнения — на фоне 5–120 секунд решения это доли миллисекунды. Заметный расход ресурсов появляется не от самой трассировки, а от избыточной кардинальности атрибутов (см. таблицу выше).

Трассировать каждое решение или включить выборку в проде?

В разработке — трассируйте всё, это упрощает отладку новых интеграций. В проде включайте выборку (например, 10 % успешных решений), а ошибки и таймауты — на 100 %, потому что именно они чаще всего требуют разбора инцидента.

Как трассировка помогает разобраться в ERROR_WRONG_CAPTCHA_ID?

captcha.error попадает в span вместе с captcha.id — видно, на каком шаге (submit или poll) попытка сломалась.

Какие атрибуты добавить сверх примера из статьи?

Полезны http.status_code на span'е submit, номер попытки на captcha.poll.attempt и итоговый captcha.solve_time_s на родительском span'е — код выше уже их проставляет, остаётся только вывести их на дашборд.

Следующие шаги

Трассировка сама по себе не чинит медленный пайплайн — она просто убирает гадание из отладки. Дальше пригодятся:

Комментарии для этой статьи отключены.