DevOps & Scaling

用于验证码解决管道的 OpenTelemetry 跟踪

某次识别用了 47 秒而不是 5 秒,是提交慢、轮询多,还是网络延迟?没有链路追踪只能靠猜。OpenTelemetry(OTel)把链路每一步变成可查询的跨度(span)——检测一次,导出到 Jaeger、Datadog 或任何兼容 OTel 的后端,供应商中立。

OpenTelemetry 追踪结构:一次 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

父跨度 captcha.solve 覆盖整条链路;提交、轮询、写入 token 各是独立子跨度,按阶段筛选即可看出耗时花在哪一步。

Python 接入 OpenTelemetry 链路追踪

安装依赖

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

国内镜像装包可加 -i https://pypi.tuna.tsinghua.edu.cn/simple

编写追踪代码

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

三层跨度分工明确:captcha.solve 父跨度,captcha.submit 覆盖提交,captcha.poll 及子跨度记录每次轮询。出错用 set_status(StatusCode.ERROR, ...) 把原因写进跨度,不用翻日志。

Node.js 接入 OpenTelemetry 链路追踪

安装依赖

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

结构与 Python 版本对称,用 startActiveSpan 回调代替 with 语句;漏掉 finally 里的 span.end() 的后果见下面排查表第二行。

配置 OTel Collector 导出追踪数据

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

观测平台(如 Datadog、New Relic)多在境外,进程直连易受跨境链路影响。先发到本地 Collector 统一批处理、重试再转发,比各进程直连海外 SaaS 更可控。

追踪里能看到哪些关键信息

跨度属性 示例值 能看出什么
captcha.type recaptcha_v2 哪种验证码类型最耗时
captcha.solve_time_s 24.5 实际识别延迟
captcha.poll.count 5 轮询了多少次才拿到结果
captcha.error ERROR_WRONG_CAPTCHA_ID 错误类型分布
captcha.id 73519... 定位某一次具体的解决记录

排查清单:追踪数据不对劲时怎么办

问题 原因 处理方式
完全看不到任何追踪 OTel Collector 没有启动 检查 docker ps,确认 endpoint 地址是否正确
子跨度丢失 跨度没有正确结束 始终在 finally 块里调用 span.end()
追踪链路断裂、上下文不连续 上下文没有正确传播 使用 startActiveSpan,让上下文自动传播
出现 High cardinality 告警 属性取值的唯一组合太多 不要把 captcha.id 当作 metrics 的标签使用

常见问题

自建 Jaeger 还是接入 Datadog/New Relic 这类 SaaS 观测平台?

不冲突——Collector 是解耦层,同一份数据可同时转发给自建 Jaeger 和 Datadog。平台未定或海外出站不稳时,先落一份到自建 Jaeger 更省心。

追踪会不会拖慢验证码识别速度?

几乎不会。异步批量导出,每个跨度只增加微秒级开销,相对 5–120 秒的单次识别耗时可忽略。

生产环境该用多高的采样率?

开发阶段可全量追踪;上线后按比例采样(如 10%)控制成本,失败请求单独设为 100% 采样,避免漏排查。

轮询次数一多,会不会导致跨度基数(cardinality)过高?

跨度名称本身安全,但别把每次轮询生成的 captcha.id 塞进 metrics 标签,留在 span 属性里查询即可。

下一步

想拿到完整链路追踪?用 CaptchaAI API Key 开始,几行代码接入 OpenTelemetry。

相关指南:

该文章已禁用评论。