السجلات على نطاق واسع: ELK وLoki

معايير السجلات المنظَّمة

18 دقيقة الدرس 2 من 28

معايير السجلات المنظَّمة

قبل أن تتمكن من إرسال السجلات إلى Elasticsearch أو الاستعلام عنها في Loki أو التنبيه عليها في Grafana، عليك أن تقرر ما هو سطر السجل فعلاً. هذا القرار — الذي يُتخذ مرة واحدة بشكل غير متسق عبر خمسين خدمة — هو السبب في أن معظم خطوط أنابيب السجلات تتحول إلى مقابر من الضوضاء غير القابلة للاستعلام. يؤسس هذا الدرس المعايير الإنتاجية التي تستخدمها شركات مثل Stripe وCloudflare وGitHub لجعل سجلاتها مصدراً موثوقاً للحقيقة عند مئات الآلاف من الأحداث في الثانية.

لماذا تفشل السجلات غير المنظَّمة على نطاق واسع

سطر السجل التقليدي غير المنظَّم يبدو هكذا: 2024-03-12 14:23:01 ERROR checkout failed for user 42 after 3201ms. يستطيع الإنسان قراءته. أما الآلة فلا تستطيع تحليله بشكل موثوق. لاستخراج معرف المستخدم أو المدة أو نوع الخطأ من مليون سطر كهذا، يجب عليك كتابة regex هشة — وفي اللحظة التي يغير فيها أحد الفرق تنسيق سجلاته، تنكسر الـ regex ولوحة القيادة تعيد أصفاراً بصمت.

السجل المنظَّم يستبدل الرسائل النصية الحرة بمستندات مفتاح/قيمة قابلة للقراءة آلياً — JSON في معظم الأحيان. كل حقل هو سمة مسمّاة ومكتوبة. السجل أعلاه يتحول إلى كائن JSON حيث user_id وduration_ms وlevel حقول من الدرجة الأولى يمكنك فهرستها وتصفيتها وتجميعها دون أي منطق تحليل وقت الاستعلام. هذا ليس مجرد راحة للمطور — إنه المتطلب المعماري الأساسي لعمل التسجيل المركزي.

الفكرة الأساسية: البنية ليست عن JSON لذاتها. إنها عن جعل كل بُعد ذي معنى في حدث السجل حقلاً قابلاً للاستعلام وقت الكتابة، حتى لا تحتاج أبداً إلى تحليل النص وقت الاستعلام. التحليل وقت الاستعلام مكلف وهش، ومستحيل الإجراء بأثر رجعي على البيانات التاريخية.

مخطط JSON القانوني للسجلات

لا يوجد معيار رسمي واحد، لكن الصناعة تقاربت على مجموعة صغيرة من الحقول الأساسية الإلزامية التي يجب أن تحملها كل سطر سجل، بصرف النظر عن أي خدمة أو لغة تُصدرها. هذه الحقول هي ما يسمح لمجمّع سجلاتك بدمج تدفقات من واجهة برمجية بـ Go وعامل Python وخدمة Node.js في مجموعة بيانات متماسكة قابلة للاستعلام المشترك.

{ "timestamp": "2024-03-12T14:23:01.847Z", "level": "error", "service": "checkout-api", "version": "v2.14.3", "env": "production", "host": "pod-checkout-api-6f4d9b-xk7p2", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7", "request_id": "req_01HXYZ9ABCDE", "user_id": "usr_42", "message": "payment gateway timeout", "duration_ms": 3201, "http.method": "POST", "http.path": "/v1/checkout", "http.status": 504, "error.type": "GatewayTimeout", "error.stack": "checkout.go:214 ..." }

لنستعرض أهم مجموعات الحقول والمنطق الكامن وراء كل منها.

اصطلاحات الحقول: لماذا كل مفتاح

الطابع الزمني. دائماً ISO 8601 بتوقيت UTC مع دقة المللي ثانية: 2024-03-12T14:23:01.847Z. لا تستخدم أعداد Unix epoch الصحيحة في سجلات التطبيق — فهي غير قابلة للقراءة دون محول، وكل واجهة سجلات ستُعيد تحليل السلسلة ISO على أي حال. اللاحقة Z إلزامية؛ السجلات التي لا تحملها تخلق غموضاً حين تمتد الخدمات عبر مناطق.

المستوى. استخدم بالضبط هذه الخمسة بأحرف صغيرة: debug، info، warn، error، fatal. لا تخترع critical أو severe أو INFORMATION. تكاثر الحالات المختلطة والمرادفات يكسر مرشحات مستوى السجل عبر المجمّع. يجب أن تُصدر خدمات الإنتاج info وما فوقه بشكل افتراضي؛ debug يُفعَّل ديناميكياً عبر علامة ميزة أو متغير بيئة، ولا يُترك دائماً.

الخدمة والإصدار والبيئة. هذه الثلاثة معاً تُحدد ما أصدر السجل. يجب أن يطابق service اسم خدمة Kubernetes بالضبط — هذا يسمح بالربط التلقائي بين المقاييس والسجلات دون جدول تعيين. يجب أن يكون version علامة semver الكاملة أو SHA الـ git، حتى تتمكن من ربط التدهور بنشر محدد. يجب أن يكون env أحد development أو staging أو production — هذا يمنع ضوضاء التدريج من تلويث لوحات الإنتاج.

مفاتيح الحقول ذات المساحات الاسمية. استخدم ترميز النقطة للتجميع حسب النطاق: http.method، http.status، db.query_ms، error.type، error.stack. هذا يعكس اصطلاحات الدلالات في OpenTelemetry، مما يعني أن سجلاتك ستتوافق تلقائياً مع التتبعات حين تضيف التتبع الموزع. المفاتيح المسطحة غير ذات المساحات الاسمية لكل شيء خطأ شائع يؤدي إلى تعارضات بين الفرق.

ممارسة احترافية: تبنَّ اصطلاحات دلالات OpenTelemetry لأسماء حقولك — حتى لو لم تستخدم OTel للتتبع بعد. إنها اللغة المشتركة الناشئة للصناعة. حين تضيف التتبعات لاحقاً، تتعيّن حقول سجلك مباشرة على سمات الـ span بدون أي تكلفة ترحيل. المساحات الاسمية الرئيسية التي يجب إتقانها: http.*، db.*، messaging.*، rpc.*، error.*.

معرفات الارتباط ومعرفات الطلبات

الممارسة الأكثر فائدة في التسجيل المنظَّم هي نشر معرف فريد عبر كل سطر سجل ينتمي إلى نفس الطلب. بدون ذلك، حين ترى خطأً في الخدمة C، لا توجد طريقة للعثور على السجلات الأعلى مستوى في الخدمتين A وB التي سبقته. مع request_id، تكتب قيمة واحدة في واجهة استعلام السجلات وترى فوراً الرواية الكاملة للطلب عبر جميع الخدمات.

هناك نوعان مختلفان من المعرفات في أنظمة الإنتاج، والخلط بينهما يسبب ألماً تشغيلياً:

  • request_id — يُولَّد في بوابة API أو موازن الحمل لكل طلب HTTP وارد. نطاقه سلسلة استدعاء متزامنة واحدة. استخدمه لإعادة بناء ما حدث خلال معاملة HTTP واحدة. تنسيق جيد هو ULID (قابل للترتيب معجمياً، مسبوق بالوقت): req_01HXYZ9ABCDE.
  • trace_id — معرف W3C TraceContext بـ 128 بت يمتد عبر الحدود اللامتزامنة وقفزات قوائم الانتظار والخدمات المتعددة. هذا هو معرف التتبع الموزع الخاص بك. يُولَّد مرة واحدة لكل عملية مرئية للمستخدم ويُنشر عبر ترويسات HTTP (traceparent) وبيانات تعريف قوائم انتظار الرسائل. استخدمه لربط السجلات بالتتبعات في Tempo أو Jaeger أو X-Ray.

يجب حقن كلا المعرفين في سياق التسجيل على مستوى الإطار حتى يحمل كل سطر سجل يُصدر خلال ذلك الطلب كليهما تلقائياً — دون مطالبة كل مطور بتذكر تمريرهما يدوياً. في Go يعني ذلك استخدام context.Context وwares وسيطة تستدعي log.With(ctx, "trace_id", traceID). في Python هو مرشح تسجيل؛ في Node.js هو AsyncLocalStorage. الآلية تختلف لكن المبدأ عالمي: المعرفات تنتقل مع سياق الطلب.

Correlation ID propagation across services Client Browser / App API Gateway generates: request_id = req_01HX... trace_id = 4bf92f35... injects → traceparent header Order Service reads traceparent logs trace_id + request_id emits child span Payment Service same trace_id propagated logs all fields in context emits child span Log Backend Query: trace_id=4bf92f35 → all logs from all services for this request, in order HTTP call call logs
trace_id واحد و request_id ينتشران عبر API Gateway وOrder Service وPayment Service يتيحان إعادة بناء الرواية الكاملة للطلب في قاعدة بيانات السجلات باستعلام واحد.

ما يجب تسجيله — وما يجب تجنبه

كل سطر سجل يُكلّف مالاً وزمن استجابة. على نطاق Google، حقل إضافي واحد مضاف لكل سطر سجل يمكن أن يُكلّف مئات الآلاف من الدولارات سنوياً في التخزين. الانضباط يعني معرفة ما يجب تضمينه.

سجّل عند كل حدود خدمة: الطلب الوارد (الطريقة والمسار وهوية المُستدعي)، والاستدعاء الصادر (الوجهة والطريقة وملخص المعاملات)، والنتيجة (الحالة والمدة والخطأ إن وُجد). هذا هو الحد الأدنى لإعادة بناء رحلة الطلب. لا تسجّل كل استدعاء دالة داخلي — ذلك يعود للتتبعات لا السجلات.

لا تسجّل الأسرار أو البيانات الشخصية أبداً — كلمات المرور والرموز وأرقام بطاقات الائتمان وأرقام الهوية وعناوين البريد الإلكتروني الكاملة. استخدم إخفاء الحقول على مستوى مكتبة التسجيل أو خطوة معالجة مسبقة في الشاحن. في كود التطبيق: "card_number": "[REDACTED]". أطر الامتثال العديدة (PCI-DSS وGDPR وHIPAA) تجعل هذا متطلباً صارماً.

مصيدة إنتاجية: خطأ شائع هو تسجيل هيئات طلبات HTTP الكاملة للتصحيح. عند حركة مرور منخفضة يبدو هذا غير ضار. على نطاق الإنتاج فهو: (1) يُسرّب البيانات الشخصية والأسرار، (2) يُضاعف حجم سجلاتك 10-100 مرة وتكاليف التخزين معها، (3) يُشبع شاحن السجلات تحت الحمل مسبباً فقدان السجلات في أسوأ لحظة — خلال حادث. سجّل بيانات تعريف الطلب (المسار وملخص الترويسات وطول المحتوى) ولا تسجّل الهيئة أبداً. إن احتجت الهيئات للتصحيح، استخدم التتبع الموزع مع أخذ العينات، لا السجلات.
# Go: تسجيل منظَّم مع zerolog + معرف الارتباط من السياق # الـ middleware يحقن trace_id و request_id في كل مسجّل في السياق. package middleware import ( "net/http" "time" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "go.opentelemetry.io/otel/trace" ) func RequestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { span := trace.SpanFromContext(r.Context()) traceID := span.SpanContext().TraceID().String() spanID := span.SpanContext().SpanID().String() // إرفاق المعرفات بالمسجّل؛ كل استدعاءات log اللاحقة ترثها. logger := log.With(). Str("trace_id", traceID). Str("span_id", spanID). Str("request_id", r.Header.Get("X-Request-Id")). Str("method", r.Method). Str("path", r.URL.Path). Logger() ctx := logger.WithContext(r.Context()) start := time.Now() rec := &statusRecorder{ResponseWriter: w, status: 200} next.ServeHTTP(rec, r.WithContext(ctx)) zerolog.Ctx(ctx).Info(). Int("http.status", rec.status). Int64("duration_ms", time.Since(start).Milliseconds()). Str("service", "checkout-api"). Str("version", "v2.14.3"). Str("env", "production"). Msg("request completed") }) }

انضباط مستوى الخطورة في الإنتاج

في الواقع، معظم الفرق تعاني من مشكلتين: سجلات info بالغة الإسهاب تُغرق الإشارات الحقيقية، وسجلات error مثقلة جداً لدرجة أنها تفقد معناها. وضع عقد واضح لكل مستوى — مُطبَّق في مراجعة الكود — أمر ضروري.

  • debug — الحالة الداخلية المفيدة فقط حين تصحح نشطاً. معطّل في الإنتاج بشكل افتراضي. يُفعَّل لكل خدمة عبر علامة ديناميكية.
  • info — الأحداث التجارية الطبيعية الذات المعنى: طلب مُستلم، مهمة بدأت، دفع مُصرَّح. واحد لكل عملية من المستوى الأعلى. لا تُصدر info أبداً في حلقة ضيقة.
  • warn — الشذوذات القابلة للتعافي التي تحتاج مراقبة لكن لا تستدعي إجراءً فورياً: نجح إعادة المحاولة بعد فشل، استُخدمت واجهة برمجية مهجورة، قيمة إعداد مفقودة مع تطبيق افتراضي.
  • error — فشلت عملية وتعذّر على النظام التعافي بمفرده. قد يحتاج إنسان للتدخل. كل سجل error يجب أن يتضمن error.type وerror.stack.
  • fatal — لا يمكن للعملية الاستمرار. يخرج التطبيق فوراً بعد إصدار هذا السطر. استخدمه بتحفظ شديد؛ هو ليس بديلاً لـerror.

الاختبار التشغيلي: إذا استيقظ أحدهم الساعة 3 صباحاً بسبب تنبيه error، فكل سجل error يجب أن يصف شيئاً يستحق الاستيقاظ من أجله. إن لم يكن كذلك، خفّضه إلى warn. إرهاق التنبيهات يبدأ بمستوى خطورة سجل مصنَّف بشكل خاطئ.

ممارسة احترافية: نفّذ "ميزانية سجل" لكل نقطة نهاية في منصة الملاحظة الخاصة بك. في Datadog وGrafana Cloud يمكنك ضبط تنبيهات مبنية على السجلات تُطلق حين تُصدر خدمة واحدة أكثر من N سطراً في الدقيقة — حلقة تسجيل غير محكومة هي هجوم حرمان من الخدمة على خط أنابيبك الخاص. في Shopify، مهمة واحدة أطلقها تاجر وكانت تسجّل بمستوى debug في الإنتاج أوقفت بنية تسجيلهم الكاملة لمدة 20 دقيقة قبل أن يلتقطها تنبيه حجم السجل.