Структурированный лог решения CAPTCHA — это одна JSON-строка на каждое значимое событие задачи: отправку, получение токена, ошибку, тайм-аут. В строке есть task_id, тип задачи, время решения и код ошибки, поэтому вопрос «почему ночью просела доля успешных решений» закрывается фильтром по одному полю, а не чтением текстового файла глазами.
Ниже — рабочая схема для Python (structlog) и Node.js (pino): какие события писать, какие поля обязательны для корреляции, как выбрать сбои через jq и как повесить оповещение на долю ошибок. Код показан на интеграции с API CaptchaAI: отправка задачи в in.php, опрос res.php, возврат токена.
Какие события писать в лог
Полезный набор событий у задачи решения CAPTCHA небольшой, и расширять его почти никогда не нужно:
captcha_submit_start— запрос сформирован, тип задачи и целевая страница уже известны.captcha_submitted— задача принята, появилсяtask_id; фиксируем время отправки.captcha_solved— токен получен; пишем время решения и число запросов опроса.captcha_solve_failed— сервис вернул код ошибки, он же попадает в полеerror.captcha_solve_timeout— окно опроса закончилось без результата.
Отдельная запись на каждую итерацию опроса — самая частая причина того, что логи становятся бесполезными: почти весь объём занимает ожидаемое состояние CAPCHA_NOT_READY, а редкие настоящие ошибки в нём тонут.
Обычный текст и JSON: в чём разница
| Строка в текстовом логе | Событие в JSON |
|---|---|
Captcha solved in 12.3s |
{"event":"captcha_solved","task_id":"abc123","type":"recaptcha_v2","solve_time_ms":12300} |
| Разбирается только регулярными выражениями | Читается любым парсером и коллектором |
| Поиск сводится к grep по подстроке | Фильтр по любому полю: типу, коду ошибки, длительности |
| События одной задачи ничем не связаны | task_id сшивает отправку, опрос и подстановку токена |
Python: настройка structlog
Конфигурация занимает несколько строк: временная метка в формате ISO, уровень события и рендер в JSON.
import structlog
import time
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(),
],
logger_factory=structlog.PrintLoggerFactory(),
)
log = structlog.get_logger()
Жизненный цикл задачи в логах
Ключевой приём — log.bind(): контекст задачи (тип, адрес страницы, обрезанный sitekey) привязывается один раз, а task_id добавляется к тому же логгеру сразу после ответа in.php. Дальше каждое событие несёт полный набор полей, и передавать их руками уже не нужно.
import requests
API_KEY = "YOUR_API_KEY"
def solve_captcha(captcha_type, sitekey, page_url, proxy=None):
solve_log = log.bind(
captcha_type=captcha_type,
site_url=page_url,
sitekey=sitekey[:12] + "...",
)
# Submit
start = time.time()
solve_log.info("captcha_submit_start")
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
"json": "1",
}).json()
if resp["status"] != 1:
solve_log.error("captcha_submit_failed", error=resp["request"])
return None
task_id = resp["request"]
submit_ms = int((time.time() - start) * 1000)
solve_log = solve_log.bind(task_id=task_id)
solve_log.info("captcha_submitted", submit_ms=submit_ms)
# Poll
for attempt in range(24):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get", "id": task_id, "json": "1"
}).json()
if result["status"] == 1:
solve_ms = int((time.time() - start) * 1000)
solve_log.info(
"captcha_solved",
solve_time_ms=solve_ms,
poll_attempts=attempt + 1,
token_length=len(result["request"]),
)
return result["request"]
if result["request"] != "CAPCHA_NOT_READY":
solve_log.error(
"captcha_solve_failed",
error=result["request"],
poll_attempts=attempt + 1,
)
return None
solve_log.warning("captcha_solve_timeout", poll_attempts=24)
return None
Здесь важны две детали. API-ключ не попадает в лог ни разу, а sitekey обрезан до двенадцати символов: этого достаточно, чтобы отличить один стенд от другого, и недостаточно, чтобы вытащить из логов чужую конфигурацию. Токен тоже не пишется целиком — в событии остаётся только token_length.
Что попадает в вывод:
{"event":"captcha_submit_start","captcha_type":"recaptcha_v2","site_url":"https://example.com","sitekey":"6Le-wvkSAAAA...","timestamp":"2025-07-15T10:30:00Z","level":"info"}
{"event":"captcha_submitted","task_id":"71845302","submit_ms":245,"timestamp":"2025-07-15T10:30:00Z","level":"info"}
{"event":"captcha_solved","task_id":"71845302","solve_time_ms":18230,"poll_attempts":4,"token_length":580,"timestamp":"2025-07-15T10:30:18Z","level":"info"}
Node.js: pino и дочерние логгеры
В Node.js та же схема строится на log.child(): дочерний логгер наследует поля родителя, поэтому контекст задачи не приходится собирать заново в каждом вызове.
const pino = require('pino');
const log = pino({
level: 'info',
timestamp: pino.stdTimeFunctions.isoTime,
});
Тот же жизненный цикл на Node.js
const axios = require('axios');
const API_KEY = 'YOUR_API_KEY';
async function solveCaptcha(captchaType, sitekey, pageUrl) {
const taskLog = log.child({
captchaType,
siteUrl: pageUrl,
sitekey: sitekey.substring(0, 12) + '...',
});
const start = Date.now();
taskLog.info('captcha_submit_start');
const submit = await axios.post('https://ocr.captchaai.com/in.php', null, {
params: {
key: API_KEY, method: 'userrecaptcha',
googlekey: sitekey, pageurl: pageUrl, json: 1,
},
});
if (submit.data.status !== 1) {
taskLog.error({ error: submit.data.request }, 'captcha_submit_failed');
return null;
}
const taskId = submit.data.request;
const boundLog = taskLog.child({ taskId });
boundLog.info({ submitMs: Date.now() - start }, 'captcha_submitted');
for (let attempt = 1; attempt <= 24; attempt++) {
await new Promise(r => setTimeout(r, 5000));
const poll = await axios.get('https://ocr.captchaai.com/res.php', {
params: { key: API_KEY, action: 'get', id: taskId, json: 1 },
});
if (poll.data.status === 1) {
boundLog.info({
solveTimeMs: Date.now() - start,
pollAttempts: attempt,
tokenLength: poll.data.request.length,
}, 'captcha_solved');
return poll.data.request;
}
if (poll.data.request !== 'CAPCHA_NOT_READY') {
boundLog.error({ error: poll.data.request, pollAttempts: attempt }, 'captcha_solve_failed');
return null;
}
}
boundLog.warn({ pollAttempts: 24 }, 'captcha_solve_timeout');
return null;
}
Если сервис написан сразу на двух языках, договоритесь об общих именах полей до того, как логи поедут в коллектор. solve_time_ms в Python и solveTimeMs в Node.js — это два разных поля для любого дашборда, и сводить их потом дороже, чем один раз принять соглашение.
Справочник полей события
| Поле | Тип | Что означает |
|---|---|---|
event |
строка | Имя события: captcha_submitted, captcha_solved и другие |
task_id |
строка | ID задачи CaptchaAI — по нему сшиваются все записи |
captcha_type |
строка | recaptcha_v2, turnstile, image и другие |
site_url |
строка | Адрес целевой страницы |
solve_time_ms |
целое | Время от отправки задачи до готового токена |
poll_attempts |
целое | Сколько запросов опроса потребовалось |
error |
строка | Код ошибки, возвращённый API |
token_length |
целое | Длина полученного токена |
Фильтрация, поиск и оповещения
Найти все сбои за последний час
# With jq
cat captcha.log | jq 'select(.level == "error" and .event == "captcha_solve_failed")'
Предупредить о росте доли ошибок
Скользящее окно последних задач надёжнее суточных сводок: деградация на конкретном типе задач видна за минуты, а не на следующее утро.
# Count errors vs successes in a rolling window
from collections import deque
class ErrorRateMonitor:
def __init__(self, window_size=100, threshold=0.2):
self.results = deque(maxlen=window_size)
self.threshold = threshold
def record(self, success):
self.results.append(success)
if len(self.results) >= 50:
error_rate = 1 - sum(self.results) / len(self.results)
if error_rate > self.threshold:
log.warning(
"captcha_error_rate_high",
error_rate=round(error_rate, 3),
window=len(self.results),
)
Как связать логи с расходом потоков
Тарификация CaptchaAI построена на одновременных потоках: BASIC ($15/мес, 5 потоков), STANDARD ($30/мес, 15 потоков), ADVANCE ($90/мес, 50 потоков). Логи — самый дешёвый способ понять, упирается ли пайплайн в число потоков или в само время решения.
Практический пример: команда из Алматы гоняет ночные интеграционные тесты на европейском регионе облака, и ночью solve_time_ms вырастает с 15 000 до 40 000 мс. Дальше решают два других поля. Если вместе с ним пропорционально растёт poll_attempts, задача действительно дольше решается — это вопрос к параллелизму воркеров и сетевым задержкам. Если же растёт submit_ms, а в поле error появляется ERROR_NO_SLOT_AVAILABLE, упор идёт в число одновременных потоков: задачи не принимаются, пока не освободится слот. Первый случай лечится расписанием прогонов, второй — тарифом с большим числом потоков. Цены зафиксированы в USD, поэтому расчёт одинаково читается для распределённой команды.
Отдельный вопрос — содержимое логов. Если рядом с событиями задачи пишутся данные из тестируемой формы, это уже персональные данные со всеми требованиями 152-ФЗ и сопоставимых норм для трансграничных команд. Практичное правило: в лог попадают только технические поля из таблицы выше, а значения формы маскируются на уровне процессора логгера.
Частые проблемы и что с ними делать
| Симптом | Причина | Решение |
|---|---|---|
| Логов слишком много | Пишется каждая итерация опроса | Оставить отправку, успех, ошибку и тайм-аут |
| События не связываются между собой | Нет общего идентификатора | Привязать task_id через log.bind() или log.child() сразу после ответа in.php |
| По логам невозможно искать | Формат — свободный текст | Перейти на JSON: structlog в Python, pino в Node.js |
| В логах видны секреты | Пишется полный API-ключ | Не логировать ключ, sitekey обрезать, токен заменять его длиной |
| Дашборд не собирается из двух сервисов | Разные имена полей | Зафиксировать единую схему полей до подключения коллектора |
Часто задаваемые вопросы
Как связать отправку и получение токена, если задач тысячи в час?
По task_id из ответа in.php. Привяжите его к логгеру сразу после отправки — тогда выборка по одному идентификатору покажет всю историю задачи: отправку, число запросов опроса и итог.
Что нельзя писать в логи решения CAPTCHA?
API-ключ, полный токен и данные из тестируемой формы. Токен раздувает объём (500+ символов на запись), а данные формы переводят логи в категорию персональных данных. Технических полей из справочника выше хватает для разбора инцидента.
Сколько хранить такие логи и какой объём они занимают?
Одно событие в JSON — примерно 200–300 байт, то есть четыре события на задачу дают около 1 КБ. При 50 000 задач в сутки это порядка 50 МБ в день. Рабочая практика: 7–14 дней в горячем хранилище для разбора инцидентов и агрегаты (доля ошибок, медиана solve_time_ms) на длинном горизонте.
Как отличить рост времени решения от роста доли ошибок?
Стройте два ряда из одних и тех же событий: медиану solve_time_ms по успешным задачам и долю captcha_solve_failed от общего числа. Растёт только первая метрика — вопрос к параллелизму и сетевым задержкам; растёт вторая, особенно с повторяющимся кодом в поле error, — вопрос к самой интеграции и к типу задачи.
Нужен ли отдельный лог под каждый тип задачи?
Нет, достаточно поля captcha_type в общем потоке событий. Разделять индексы имеет смысл только тогда, когда нагрузка по типам различается на порядки и мешает выборкам по редкому типу.
Соберите наблюдаемые процессы решения CAPTCHA на CaptchaAI
Получите API-ключ на сайте CaptchaAI и подключите логи по схеме выше.