DevOps والتوسع

إنشاء تنبيهات CaptchaAI مخصصة باستخدام PagerDuty

تراقب تنبيهات PagerDuty المدمجة مع CaptchaAI ثلاث إشارات لا تحتمل التأجيل: انخفاض الرصيد، وارتفاع معدل الأخطاء، وتوقف العمال عن المعالجة. بدل أن يكتشف فريقك تعطّل خط الأنابيب من شكاوى المستخدمين صباحاً، يصل التنبيه إلى المهندس المناوب خلال ثوانٍ ومعه سياق كافٍ لتشخيص السبب دون التنقيب في السجلات. يبني هذا الدليل المسار كاملاً عبر Events API v2 بأمثلة جاهزة بلغتي Python وJavaScript.

لماذا تحتاج خطوط أنابيب CaptchaAI إلى تنبيهات فورية

خدمة حل الكابتشا تعمل في قلب عمليات كثيرة: التحقق من المخزون، واختبارات الجودة (QA)، وجمع البيانات المجدول. حين تتوقف، تتوقف معها هذه العمليات بصمت دون رسالة خطأ صريحة على الشاشة. لذلك تكون المراقبة اللحظية أرخص كثيراً من اكتشاف العطل متأخراً.

تخيّل فريقاً في متجر إلكتروني بمنطقة الخليج يشغّل CaptchaAI على خطة ADVANCE ($90 شهرياً مع 50 thread) لمعالجة طلبات التحقق أثناء حملة تخفيضات ليلية. لأن CaptchaAI يحاسب على أساس عدد الـ threads المتزامنة مع حلول غير محدودة لكل thread، فإن العطل هنا لا يكون في «تجاوز حصة» بل في نفاد الرصيد الشهري أو ارتفاع مفاجئ في معدل الأخطاء. عشر دقائق من عطل صامت خلال ذروة الحملة قد تعني آلاف الطلبات الفاشلة قبل أن ينتبه أحد. التنبيه الفوري يحوّل هذا من أزمة صباحية إلى إشعار يُعالَج خلال دقائق.

الإشارات الثلاث التي تستحق تنبيهاً تقريباً في كل خط أنابيب هي: الرصيد الذي يقترب من النفاد، ومعدل الأخطاء الذي يقفز بعد نشر إصدار جديد أو تغيّر في بيئة الوكيل (proxy)، وتوقف العمال عن سحب المهام من قائمة الانتظار. بقية القياسات — مثل زمن الحل — مفيدة للتسجيل لكنها نادراً ما تستحق استدعاء إنسان في منتصف الليل.

مصفوفة الشدة: متى تُصعّد التنبيه ومتى تكتفي بالتسجيل

قبل كتابة أي كود، حدّد ما الذي يوقظ إنساناً وما الذي يُسجَّل فقط. المصفوفة التالية تربط كل حالة بمستوى شدة وإجراء PagerDuty مناسب:

الشدة الشرط إجراء PagerDuty
حرجة الرصيد أقل من $2 استدعاء المهندس المناوب فوراً
حرجة توقّف جميع العمال استدعاء المهندس المناوب فوراً
عالية معدل الأخطاء أعلى من 20% لمدة 5 دقائق فتح حادثة عاجلة
تحذير الرصيد أقل من $10 فتح حادثة منخفضة الاستعجال
تحذير عمق قائمة الانتظار أكثر من 100 لمدة 10 دقائق فتح حادثة منخفضة الاستعجال
معلومات زمن الحل p95 أعلى من 120 ثانية الإضافة إلى حادثة قائمة أو التسجيل فقط

القاعدة العملية: احصر مستوى «حرجة» — الذي يتصل بالهاتف — في الحالات التي توقف الإنتاج فعلاً، مثل نفاد الرصيد أو سقوط كل العمال. اجعل التحذيرات حوادث منخفضة الاستعجال تُعالَج ضمن ساعات العمل، ووجّه إشارات «معلومات» إلى السجل فقط. هذا الفصل هو أهم دفاع ضد إرهاق التنبيهات (alert fatigue) الذي يجعل الفريق يتجاهل الإشعارات مع الوقت.

إعداد خدمة PagerDuty لخط أنابيب CaptchaAI

يبدأ المسار من جانب PagerDuty قبل كتابة أي سطر برمجي. أنشئ الخدمة وسياسة التصعيد أولاً، ثم مرّر مفتاح التوجيه إلى الكود عبر متغيّر بيئة:

الخطوة الإجراء
1 أنشئ خدمة في PagerDuty باسم "CaptchaAI Pipeline"
2 أضِف تكامل Events API v2 إلى الخدمة
3 انسخ مفتاح التوجيه (routing key) إلى متغيّر البيئة PAGERDUTY_ROUTING_KEY
4 أعدّ سياسة التصعيد: المناوب ثم قائد الفريق ثم المدير
5 اضبط قواعد الإشعار: الدفع والرسائل القصيرة والاتصال الهاتفي
6 أضِف نوافذ صيانة (maintenance windows) لأوقات التوقف المخطط لها

تخزين مفتاح التوجيه في متغيّر بيئة — لا في الكود — يجعل تدوير المفتاح أو نقل السكربت بين البيئات آمناً دون تعديل المصدر.

Python: الربط عبر PagerDuty Events API v2

يجمع المثال التالي فئتين: CaptchaPagerDuty التي تغلّف إجراءات trigger وacknowledge وresolve عبر Events API v2، وCaptchaMonitor التي تتابع الرصيد ومعدل الأخطاء على نافذة متحركة مدتها 5 دقائق. لاحظ استخدام dedup_key ثابتاً لكل نوع تنبيه، وإغلاق الحوادث تلقائياً عند تعافي الحالة.

import os
import time
import hashlib
import requests
from datetime import datetime

API_KEY = os.environ["CAPTCHAAI_API_KEY"]
PAGERDUTY_ROUTING_KEY = os.environ["PAGERDUTY_ROUTING_KEY"]

session = requests.Session()


class CaptchaPagerDuty:
    EVENTS_URL = "https://events.pagerduty.com/v2/enqueue"

    def __init__(self, routing_key):
        self.routing_key = routing_key

    def trigger(self, summary, severity="error", source="captcha-pipeline",
                details=None, dedup_key=None):
        """Trigger a new PagerDuty incident."""
        payload = {
            "routing_key": self.routing_key,
            "event_action": "trigger",
            "payload": {
                "summary": summary,
                "severity": severity,  # critical, error, warning, info
                "source": source,
                "timestamp": datetime.utcnow().isoformat() + "Z",
                "custom_details": details or {}
            }
        }

        if dedup_key:
            payload["dedup_key"] = dedup_key

        resp = requests.post(self.EVENTS_URL, json=payload, timeout=10)
        resp.raise_for_status()
        return resp.json()

    def resolve(self, dedup_key):
        """Resolve an existing incident."""
        payload = {
            "routing_key": self.routing_key,
            "event_action": "resolve",
            "dedup_key": dedup_key
        }
        resp = requests.post(self.EVENTS_URL, json=payload, timeout=10)
        resp.raise_for_status()
        return resp.json()

    def acknowledge(self, dedup_key):
        """Acknowledge an existing incident."""
        payload = {
            "routing_key": self.routing_key,
            "event_action": "acknowledge",
            "dedup_key": dedup_key
        }
        resp = requests.post(self.EVENTS_URL, json=payload, timeout=10)
        resp.raise_for_status()
        return resp.json()


pagerduty = CaptchaPagerDuty(PAGERDUTY_ROUTING_KEY)


class CaptchaMonitor:
    def __init__(self):
        self.error_window = []  # (timestamp, is_error)
        self.window_size = 300  # 5 minutes in seconds

    def record_solve(self, success):
        now = time.time()
        self.error_window.append((now, not success))
        # Prune old entries
        self.error_window = [
            (t, e) for t, e in self.error_window
            if now - t < self.window_size
        ]

    @property
    def error_rate(self):
        if not self.error_window:
            return 0.0
        errors = sum(1 for _, e in self.error_window if e)
        return errors / len(self.error_window)

    def check_balance(self):
        resp = session.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "getbalance", "json": 1
        })
        data = resp.json()
        if data.get("status") != 1:
            return None
        return float(data["request"])

    def run_checks(self):
        """Run all monitoring checks and trigger alerts."""
        # Check balance
        balance = self.check_balance()
        if balance is not None:
            if balance < 2:
                pagerduty.trigger(
                    summary=f"CaptchaAI balance critically low: ${balance:.2f}",
                    severity="critical",
                    dedup_key="captcha-balance-critical",
                    details={"balance": balance, "threshold": 2}
                )
            elif balance < 10:
                pagerduty.trigger(
                    summary=f"CaptchaAI balance low: ${balance:.2f}",
                    severity="warning",
                    dedup_key="captcha-balance-warning",
                    details={"balance": balance, "threshold": 10}
                )
            else:
                # Resolve if balance recovered
                try:
                    pagerduty.resolve("captcha-balance-critical")
                    pagerduty.resolve("captcha-balance-warning")
                except Exception:
                    pass  # No incident to resolve

        # Check error rate
        rate = self.error_rate
        if rate > 0.20:
            total = len(self.error_window)
            errors = sum(1 for _, e in self.error_window if e)
            pagerduty.trigger(
                summary=f"CaptchaAI error rate {rate:.0%} "
                        f"({errors}/{total} in 5 min)",
                severity="error",
                dedup_key="captcha-error-rate-high",
                details={
                    "error_rate": round(rate, 3),
                    "total_tasks": total,
                    "failed_tasks": errors,
                    "window_seconds": self.window_size
                }
            )
        elif rate < 0.05 and len(self.error_window) > 10:
            try:
                pagerduty.resolve("captcha-error-rate-high")
            except Exception:
                pass


monitor = CaptchaMonitor()

# After each solve:
# monitor.record_solve(success=True)

# Run checks every 60 seconds:
# while True:
#     monitor.run_checks()
#     time.sleep(60)

استدعِ record_solve بعد كل محاولة حل لتغذية النافذة المتحركة، وشغّل run_checks في حلقة كل 60 ثانية. يقرأ فحص الرصيد من نقطة النهاية res.php عبر الإجراء getbalance، ويقارن الناتج بالعتبتين $2 و$10 قبل إطلاق التنبيه المناسب.

JavaScript: مراقبة معدل الأخطاء والرصيد

للفرق التي تشغّل خط الأنابيب على Node.js، يقدّم المثال التالي المنطق نفسه: مراقبة الرصيد عبر getbalance من res.php، وحساب معدل الأخطاء على نافذة 5 دقائق، وإطلاق التنبيه أو إغلاقه حسب العتبات — مع تشغيل دوري كل 60 ثانية عبر setInterval.

const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;
const PD_ROUTING_KEY = process.env.PAGERDUTY_ROUTING_KEY;
const PD_EVENTS_URL = "https://events.pagerduty.com/v2/enqueue";

class PagerDutyAlerter {
  constructor(routingKey) {
    this.routingKey = routingKey;
  }

  async trigger(summary, severity = "error", details = {}, dedupKey = null) {
    const payload = {
      routing_key: this.routingKey,
      event_action: "trigger",
      payload: {
        summary,
        severity,
        source: "captcha-pipeline",
        timestamp: new Date().toISOString(),
        custom_details: details,
      },
    };
    if (dedupKey) payload.dedup_key = dedupKey;

    const resp = await axios.post(PD_EVENTS_URL, payload, { timeout: 10000 });
    return resp.data;
  }

  async resolve(dedupKey) {
    await axios.post(PD_EVENTS_URL, {
      routing_key: this.routingKey,
      event_action: "resolve",
      dedup_key: dedupKey,
    }, { timeout: 10000 });
  }
}

const alerter = new PagerDutyAlerter(PD_ROUTING_KEY);

class CaptchaHealthMonitor {
  constructor(windowMs = 300000) {
    this.results = [];
    this.windowMs = windowMs;
  }

  record(success) {
    this.results.push({ time: Date.now(), success });
    const cutoff = Date.now() - this.windowMs;
    this.results = this.results.filter((r) => r.time > cutoff);
  }

  get errorRate() {
    if (this.results.length === 0) return 0;
    const errors = this.results.filter((r) => !r.success).length;
    return errors / this.results.length;
  }

  async checkAndAlert() {
    // Balance check
    try {
      const resp = await axios.get("https://ocr.captchaai.com/res.php", {
        params: { key: API_KEY, action: "getbalance", json: 1 },
      });
      if (resp.data.status === 1) {
        const balance = parseFloat(resp.data.request);
        if (balance < 2) {
          await alerter.trigger(
            `CaptchaAI balance critically low: $${balance.toFixed(2)}`,
            "critical",
            { balance },
            "captcha-balance-critical"
          );
        } else if (balance < 10) {
          await alerter.trigger(
            `CaptchaAI balance low: $${balance.toFixed(2)}`,
            "warning",
            { balance },
            "captcha-balance-warning"
          );
        } else {
          await alerter.resolve("captcha-balance-critical").catch(() => {});
          await alerter.resolve("captcha-balance-warning").catch(() => {});
        }
      }
    } catch (err) {
      console.error("Balance check failed:", err.message);
    }

    // Error rate check
    const rate = this.errorRate;
    if (rate > 0.2 && this.results.length > 10) {
      await alerter.trigger(
        `CaptchaAI error rate: ${(rate * 100).toFixed(1)}%`,
        "error",
        { errorRate: rate, totalTasks: this.results.length },
        "captcha-error-rate"
      );
    } else if (rate < 0.05 && this.results.length > 10) {
      await alerter.resolve("captcha-error-rate").catch(() => {});
    }
  }
}

const monitor = new CaptchaHealthMonitor();

// Run checks every 60 seconds
setInterval(() => monitor.checkAndAlert(), 60000);

module.exports = { monitor, alerter };

الفارق التطبيقي الوحيد بين النسختين هو الاعتماد على axios والتشغيل الدوري عبر setInterval؛ أما مفاتيح dedup والعتبات فمتطابقة، ما يبقي سلوك التنبيه موحّداً بصرف النظر عن اللغة التي يعمل بها العامل.

معالجة أعطال التنبيهات الشائعة

معظم مشكلات التكامل تعود إلى مفتاح التوجيه أو مفتاح dedup، لا إلى الكود نفسه. الجدول التالي يلخّص أكثر الأعطال تكراراً وحلولها:

المشكلة السبب الحل
التنبيه لا يُطلَق مفتاح التوجيه غير صحيح تأكد أن المفتاح يطابق تكامل Events API في الخدمة
حوادث مكررة غياب dedup_key اضبط مفتاح dedup ثابتاً لكل نوع تنبيه
سيل من التنبيهات لا فترة تهدئة بين الإطلاقات يكبت مفتاح dedup التكرارات؛ احرص على استخدامه دائماً
الإغلاق التلقائي لا يعمل عدم تطابق مفتاح dedup استخدم في resolve المفتاح نفسه المستخدم في trigger

الأسئلة الشائعة

كم مرة ينبغي تشغيل فحوصات المراقبة؟

تشغّل الأمثلة الفحوصات كل 60 ثانية، وتحسب معدل الأخطاء على نافذة متحركة مدتها 5 دقائق (300 ثانية). هذا الإيقاع يوازن بين الاكتشاف السريع وتجنّب الضجيج؛ فالفحص كل بضع ثوانٍ يستهلك طلبات دون فائدة، والفحص كل عدة دقائق يؤخّر اكتشاف الأعطال.

ما الفرق بين تنبيه الرصيد الحرج وتنبيه الرصيد المنخفض؟

تنبيه الرصيد المنخفض (أقل من $10) حادثة منخفضة الاستعجال تُعالَج ضمن ساعات العمل، بينما تنبيه الرصيد الحرج (أقل من $2) يستدعي المناوب فوراً لأن نفاد الرصيد يوقف المعالجة بالكامل. الفصل بينهما يمنع إيقاظ أحد في منتصف الليل لمشكلة تحتمل التأجيل.

هل يمكن توجيه التنبيهات إلى Slack أو البريد الإلكتروني بدل الهاتف؟

نعم. تُضبط قنوات الإشعار داخل PagerDuty ضمن قواعد الإشعار وسياسة التصعيد، لا في الكود. وجّه الحوادث منخفضة الاستعجال إلى Slack أو البريد الإلكتروني، واحصر الاتصال الهاتفي والرسائل القصيرة بالحوادث الحرجة.

كيف أمنع فتح حادثة جديدة عند كل دورة فحص؟

استخدم dedup_key ثابتاً لكل نوع تنبيه؛ يجمع PagerDuty كل الأحداث ذات المفتاح نفسه في حادثة واحدة بدل فتح واحدة جديدة في كل دورة. وعند تعافي الحالة يُرسِل الكود إجراء resolve بالمفتاح ذاته لإغلاقها.

ماذا يحدث عندما يتعافى الرصيد أو ينخفض معدل الأخطاء؟

تتضمن الأمثلة إغلاقاً تلقائياً: حين يتجاوز الرصيد العتبة أو يهبط معدل الأخطاء دون 5%، تُستدعى دالة resolve بمفتاح dedup نفسه فتُغلق الحادثة دون تدخل يدوي، فتبقى لوحة الحوادث معبّرة عن الحالة الراهنة فقط.


الخطوات التالية

أدلة ذات صلة

التعليقات غير مفعّلة لهذا المقال.