الأدلة التقنية

Feature Flags: انشر الكود وفعل الميزة لاحقاً

كيف يفصل ال Feature Flag بين نشر الكود وإطلاق الميزة، وكيف تطلقها لجزء من المستخدمين أولاً وتوقفها فوراً إذا تعطلت.

Feature Flags: انشر الكود وفعل الميزة لاحقاً

في مقال التطوير على الفرع الرئيسي عرفنا ال Feature Flag بأنه شرط في الكود يقرر في وقت التشغيل هل يعمل المسار الجديد أم القديم، واستخدمناه بمتغير بيئة بسيط حتى ندمج كوداً غير مكتمل في main دون أن يراه أحد. وفي مقال البيئات وGitOps رأينا كيف يفتح ال Flag في staging ويغلق في الإنتاج. فماذا يحدث عندما يصبح عندك عشرون Flag، وتريد أن تفتح الميزة لعشرة بالمئة من المستخدمين ثم لنصفهم، وأن تطفئها في ثوان إذا ظهرت مشكلة، وأن تعرف من فتحها ومتى؟ هنا لا يكفي متغير البيئة، وتحتاج إلى فهم ال Feature Flags كنظام كامل.

وهذا المقال هو المدخل إلى ال Feature Flags في مرحلة منصات للمطورين، وسوف نناقش فيه ما يلي:

  • الفرق بين النشر Deploy والإطلاق Release، وأنواع ال Flags الأربعة ومدة حياة كل منها.
  • لماذا لا يكفي متغير البيئة، وكيف تعمل خدمة ال Flags Flag Service من الداخل: القواعد وسياق التقييم والتقييم المحلي.
  • الإطلاق التدريجي Gradual Rollout ولماذا لا يتبدل المستخدم بين المسارين، وال Kill Switch الذي يطفئ الميزة في ثوان.
  • معيار OpenFeature الذي يفصل كودك عن الأداة، وتراكم ال Flags المنسية Flag Debt وكيف تكشفه في CI.
  • تجربة عملية كاملة على Flagsmith مع OpenFeature SDK: نسبة 10% ثم 50%، وKill Switch، وتعطل خدمة ال Flags، وفحص ال Flags المنسية.

النشر Deploy شيء، والإطلاق Release شيء آخر

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

خط زمني من ست خطوات لإصدار واحد v2.4.0 منشور مرة واحدة: Deploy وال Flag مطفأ بنسبة 0%، ثم Staff لفريق العمل وQA، ثم 10% مع مراقبة الأخطاء، ثم 50%، ثم 100% للجميع، ثم Remove لحذف ال Flag والمسار القديم، وسهم أحمر من خطوة 50% إلى مربع Kill switch يطفئ الميزة في ثوان دون بناء أو نشر أو تراجع
نشر واحد وعدة إطلاقات، وال Kill Switch متاح في كل خطوة

وقد يتساءل البعض: أليس هذا هو الكناري Canary الذي شرحناه في استراتيجيات الإطلاق؟ والإجابة أن الفكرة واحدة والمكان مختلف، فالكناري يعمل على مستوى البنية: نسختان من التطبيق وموجه يرسل 5% من الطلبات إلى النسخة الجديدة، والتراجع يعني إعادة الطلبات إلى النسخة القديمة. أما ال Flag فيعمل داخل الكود في النسخة نفسها، ويختار المستخدمين بأسمائهم وصفاتهم وليس بالطلبات، فتستطيع أن تفتح الميزة لموظفي الشركة أو لعميل واحد أو لمستخدمي مدينة معينة، وتستطيع أن تطفئ ميزة واحدة من عشر ميزات وصلت في الإصدار نفسه. والفرق يستخدمان الأسلوبين معاً: الكناري لحماية الإصدار كله، وال Flag لحماية كل ميزة.

أنواع ال Flags الأربعة

يقسم Pete Hodgson في المقال المرجعي Feature Toggles ال Flags بحسب الغرض منها، ويسميها Toggles فيقول مثلاً Release Toggles والمعنى واحد، وهذا التقسيم مهم لأن كل نوع له مدة حياة ومالك مختلف:

  • Release Flags: تسمح بشحن كود غير مكتمل إلى الإنتاج «ككود كامن قد لا يشغل أبداً» بحسب تعبيره، وتعيش أياماً أو أسابيع، ثم تحذف بعد الإطلاق الكامل، ومالكها المطور.
  • Experiment Flags: لاختبارات A/B، حيث يوضع كل مستخدم في مجموعة ثابتة ويقاس سلوك كل مجموعة، وتعيش حتى تكتمل التجربة، ومالكها فريق المنتج، ولها مقال مخطط مع GrowthBook.
  • Ops Flags: يستخدمها فريق التشغيل لتعطيل ميزة مكلفة أو تخفيفها عند الضغط، ومنها ال Kill Switches التي يذكر المقال أنها قد تبقى طويلاً، مثل إطفاء لوحة التوصيات الثقيلة في الصفحة الرئيسية عند ذروة الزيارات.
  • Permission Flags: تفتح ميزات لفئة من المستخدمين، مثل ميزات المشتركين المدفوعين أو ميزات تجريبية لموظفي الشركة، وتعيش طويلاً.

وفي هذا المقال نركز على النوع الأول وال Kill Switches، لأنهما ما يحتاجه كل فريق من أول يوم.

الطريقة البسيطة: متغير بيئة، ولماذا لا يكفي

أبسط Flag هو متغير بيئة يقرؤه التطبيق عند التشغيل، مثل NEW_CHECKOUT=true، وهو ما استخدمناه في مثال Trunk-Based، وهو مناسب تماماً في البداية. لكنه يفشل عندما يكبر الاستخدام لأربعة أسباب:

  • تغييره يحتاج إلى إعادة تشغيل: فال Kill Switch الذي يحتاج إلى نشر جديد ليس Kill Switch حقيقياً.
  • كل شيء أو لا شيء: القيمة واحدة لكل المستخدمين، فلا تستطيع فتح الميزة لموظفيك فقط أو لعشرة بالمئة.
  • لا يوجد سجل: لا تعرف من غير القيمة ومتى، وهذا ما تحتاجه عند أول مشكلة.
  • لا توجد قائمة: ال Flags متفرقة في ملفات الإعداد لكل خدمة، ولا يعرف أحد كم Flag عندكم وأيها انتهى دوره.

والخطوة التالية التي تجربها الفرق هي جدول في قاعدة البيانات يقرؤه التطبيق، وهذا يحل إعادة التشغيل لكنه يعيدك إلى بناء أداة كاملة بنفسك: واجهة لتغيير القيم، وقواعد للنسب والمجموعات، وسجل، وصلاحيات، وتخزين مؤقت حتى لا يسأل كل طلب قاعدة البيانات. والحل الأنسب هو خدمة Flags مخصصة تستضيفها بنفسك، وهي ما سوف نشرحه الآن.

كيف تعمل خدمة ال Flags من الداخل

خدمة ال Flags مثل Flagsmith أو Unleash فيها لوحة يغير منها فريقك القواعد، وقاعدة بيانات تحفظ ال Flags والمجموعات Segments وسجل التغييرات، و SDK تضيفه إلى تطبيقك. والسؤال المهم هو أين يحدث التقييم Evaluation، أي حساب قيمة ال Flag لمستخدم معين، والصورة التالية تبين الطريقة التي استخدمناها في التجربة:

فريق العمل يغير القواعد من لوحة خدمة ال Flags التي تحفظها في قاعدة بيانات، وال SDK داخل تطبيقك يجلب القواعد كل 5 ثوان ويحفظها في الذاكرة، ثم يجمعها مع سياق التقييم targetingKey user-7 وصفة plan staff فيقيم محلياً ويخرج value true وreason TARGETING_MATCH دون اتصال بالشبكة لكل طلب، وفي الأسفل ملاحظة أن العملية تحتفظ بآخر القواعد إذا تعطلت الخدمة، والعملية الجديدة تبدأ بالقيم الافتراضية في الكود
القواعد تأتي من الخدمة، والتقييم يحدث داخل تطبيقك

وفي الصورة أعلاه لاحظ التالي:

  • سياق التقييم Evaluation Context: هو ما تخبر به ال SDK عن الطلب الحالي، وأهمه معرف ثابت للمستخدم يسمى في OpenFeature targetingKey، وصفات Traits مثل الخطة أو الدولة. والقواعد تكتب على هذه الصفات: «من خطته staff» أو «50% من المستخدمين».
  • التقييم المحلي Local Evaluation: ال SDK يجلب كل القواعد مرة كل بضع ثوان ويقيم في الذاكرة، فلا يضيف أي اتصال شبكة إلى طلبات المستخدمين. والبديل هو التقييم البعيد Remote Evaluation حيث يسأل ال SDK الخدمة عن كل مستخدم، وهو أبسط ولكنه يضيف زمن انتظار لكل طلب ويجعل تطبيقك يتعطل إذا تعطلت الخدمة.
  • القيمة وسببها: النتيجة ليست true أو false فقط، وإنما معها سبب Reason مثل TARGETING_MATCH عندما تطابق قاعدة، وDISABLED عندما يكون ال Flag مطفأ، وERROR مع رمز الخطأ عندما يفشل التقييم، وهذا ما تكتبه في السجلات عندما يسألك أحد لماذا رأى المستخدم هذه الصفحة.
  • نوعان من المفاتيح السرية: مفتاح البيئة العادي مصمم للتطبيقات في المتصفح والجوال، أما مفتاح السيرفر Server Key في Flagsmith (يبدأ ب ser.) فهو الذي يسمح بالتقييم المحلي لأنه يجلب البيئة كاملة بكل المجموعات وشروطها، ولذلك ينص توثيق Flagsmith على أنه يعامل كسر، ومكانه السيرفر فقط.

الإطلاق التدريجي Gradual Rollout

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

المستخدم user-7 يدخل مع معرف المجموعة إلى دالة hash فيخرج رقم ثابت مثل 42.7 بين 0 و100، ثم شريط من عشرين خانة من 0 إلى 95: أول خانتين باللون الأخضر تمثلان 10%، والخانات حتى 45 باللون البرتقالي تمثل 50% وتشمل خانات ال 10% الأولى، وفي مربع جانبي نتائج تجربتنا: عند 10% فتحت الميزة ل 106 من 1000 مستخدم، وعند 50% ل 481، وكل ال 106 بقوا ضمنهم
كل مستخدم في خانة ثابتة، فرفع النسبة يضيف مستخدمين ولا يخرج أحداً

في Flagsmith مثلاً يحسب ال SDK رقماً من معرف المجموعة ومعرف المستخدم، ثم يفتح الميزة إذا كان الرقم أقل من النسبة أو يساويها، وقد قرأنا ذلك في كود المحرك داخل مكتبة flagsmith-nodejs نفسها. والنتيجة ثلاث خصائص مهمة: المستخدم يرى القيمة نفسها في كل طلب وعلى كل سيرفر، ورفع النسبة من 10% إلى 50% يضيف مستخدمين جدداً ولا يخرج أحداً ممن فتحت لهم، وإدخال معرف المجموعة في الهاش يجعل مستخدمي ال 10% في ميزة غير مستخدمي ال 10% في ميزة أخرى، فلا يتحمل المستخدمون أنفسهم كل التجارب. والرقم 42.7 في الصورة للتوضيح فقط، أما الأرقام في المربع فمن تجربتنا أدناه.

ولاحظ أن الإطلاق التدريجي لا قيمة له دون قياس، فبين كل خطوة والتي تليها راقب الأخطاء وزمن الرد للمستخدمين الذين فتحت لهم الميزة، ولذلك سجل قيمة ال Flag مع كل طلب في السجلات المنظمة حتى تستطيع المقارنة بين المجموعتين.

Kill Switch والقيمة الافتراضية

ال Kill Switch هو أن تطفئ الميزة من لوحة الخدمة فتختفي من الإنتاج خلال ثوان، دون بناء ودون نشر ودون تراجع عن الإصدار، والمدة الفعلية هي فترة التحديث في ال SDK، وكانت 5 ثوان في تجربتنا. ولكي يعمل في يوم الطوارئ عليك بثلاثة أمور:

  • القيمة الافتراضية في الكود هي الأكثر أماناً: كل استدعاء لل Flag يأخذ قيمة افتراضية، مثل false في getBooleanValue('new_checkout', false, …)، وهي التي تعود إذا لم يجد ال SDK ال Flag أو لم يصل إلى الخدمة، لذلك اجعلها المسار القديم المجرب.
  • تطبيقك لا يتوقف إذا توقفت خدمة ال Flags: وهذا ليس افتراضياً في كل SDK كما سوف ترى في التجربة، فقد رفض تطبيقنا أن يبدأ عندما كانت الخدمة متوقفة حتى أضفنا القيمة الافتراضية.
  • المسار القديم يجب أن يبقى صالحاً: إذا غيرت الميزة جداول قاعدة البيانات فاجعل الترحيل متوافقاً مع المسارين، كما شرحنا في استراتيجيات الإطلاق، وإلا فإطفاء ال Flag لا يعيدك إلى حالة تعمل.

معيار OpenFeature: لا تربط كودك بأداة واحدة

كل أداة Feature Flags لها SDK بأسماء دوال مختلفة، فإذا كتبت flagsmith.getIdentityFlags(...) في مئتي مكان في الكود ثم قررت الانتقال إلى أداة أخرى، فأمامك مئتا تعديل. وOpenFeature معيار مفتوح يحل هذه المشكلة، ويعرف نفسه بأنه يقدم «SDK موحداً لتقييم ال Feature Flags يمكن توصيله بمزودي إدارة ال Flags المختلفين»، وهو مشروع في مؤسسة CNCF قبل في يونيو 2022 وانتقل إلى مستوى Incubating في نوفمبر 2023. والصورة التالية تبين أجزاءه:

كود التطبيق يستدعي flags.getBooleanValue لل Flag new_checkout وقيمة افتراضية false وtargetingKey، ثم OpenFeature SDK فيه evaluation API والسياق وال hooks والأحداث، وتحته hooks للسجلات والمقاييس والتتبع، ثم provider يتصل بأحد المزودين: Flagsmith provider في التجربة، أو Unleash وGrowthBook وflagd، أو In-memory provider للاختبارات، وفي الأسفل ملاحظة أن تغيير الأداة يعني تغيير ملف واحد
الكود يكلم OpenFeature، وال Provider وحده يعرف الأداة
  • واجهة التقييم Evaluation API: الدوال التي يستدعيها كودك، مثل getBooleanValue وgetStringValue، ونسختها getBooleanDetails التي تعيد القيمة مع السبب.
  • المزود Provider: طبقة الترجمة بين الواجهة والأداة، ويوجد مزودون لأغلب الأدوات في صفحة المنظومة، ومنهم مزود في الذاكرة للاختبارات.
  • الخطافات Hooks: كود يعمل قبل التقييم وبعده وعند الخطأ، كما في توثيق الخطافات، فتضيف السجلات والمقاييس في مكان واحد بدل كل استدعاء.

والنتيجة أن تغيير الأداة يعني تغيير ملف واحد يعرف ال Provider، وهو الملف flags.mjs في التجربة، أما استدعاءات ال Flags في باقي الكود فتبقى كما هي.

تراكم ال Flags المنسية Flag Debt

يقول Hodgson في مقاله إن «الفرق الذكية تعامل ال Feature Toggles في الكود كمخزون له تكلفة حمل، وتسعى إلى إبقائه منخفضاً قدر الإمكان». والسبب أن كل Flag ينسى بعد الإطلاق الكامل يترك خلفه شرطاً ومساراً قديماً لا يشغله أحد ولا يختبره أحد، وأن كل Flag جديد يضاعف عدد الحالات الممكنة، فعشرة Flags تعني 1024 تركيبة نظرياً، وأن Flag قديماً قد يعاد استخدام اسمه لميزة جديدة فيوقظ كوداً قديماً، وهذا ما حدث في قصة Knight Capital التي ذكرناها في البيئات وGitOps. والصورة التالية تبين دورة حياة ال Flag الصحيحة وما يحدث عند كسرها:

أربع مراحل: Create بمالك وتاريخ انتهاء وتذكرة، ثم Roll out للموظفين ثم 10% ثم 50%، ثم 100% مستقر لأسبوعين، ثم Remove بحذف الشرط والمسار القديم ثم ال Flag، وسهم أحمر متقطع من 100% مكتوب عليه forgotten إلى مربع Flag debt: ال Flag old_search مفتوح منذ سنة والفرع الآخر لا يعمل، و10 Flags تعني 1024 تركيبة، وFlag يعاد استخدامه يوقظ كوداً قديماً، وبجانبه مربع Audit in CI يظهر STALE old_search وMISSING new_chekout وexit 1
ال Flag المؤقت يولد ومعه تاريخ حذفه

والحل عملي وبسيط: كل Release Flag يكتب معه مالك وتاريخ انتهاء وتذكرة لحذفه، وفحص في خط CI يقارن ال Flags الموجودة في الخدمة بال Flags التي يقرؤها الكود، فيكشف ال Flags المنسية والأخطاء الإملائية في أسمائها، وسوف نكتبه في التجربة. ويذكر Hodgson أيضاً فكرة «القنبلة الموقوتة» Time Bomb، أي اختبار يفشل إذا بقي ال Flag بعد تاريخ انتهائه.

أدوات تستضيفها بنفسك

الأدوات المعروفة التي تستضيفها بنفسك كثيرة، والمهم أن تعرف ترخيص كل منها اليوم:

  • Flagsmith: أغلبه بترخيص BSD-3-Clause، ويذكر المستودع أن ميزات الحوكمة والإدارة على مستوى المؤسسات تحتاج إلى ترخيص Enterprise. ويدعم ال Feature Flags والإعدادات عن بعد Remote Config والمجموعات، وهو الأداة التي استخدمناها في التجربة.
  • Unleash: كان بترخيص Apache-2.0، ثم انتقل في مايو 2026 إلى AGPL-3.0 كما في طلب الدمج الذي غير الترخيص، وهذا يهمك إذا كنت تعدل الكود وتقدمه كخدمة لغيرك.
  • flagd: خدمة ال Flags الخفيفة من مشروع OpenFeature نفسه بترخيص Apache-2.0، وتقرأ ال Flags من ملف أو من Kubernetes دون لوحة إدارة.
  • GrowthBook: يركز على التجارب واختبارات A/B وقياس أثر الميزة.

وكل أداة من هذه لها مقال تثبيت مخطط في المرحلة نفسها، ثم مقارنة بينها، فاخترنا هنا Flagsmith للتجربة لأن ترخيصه متساهل وله مزود OpenFeature رسمي ل Node.js، وليس لأنه الأفضل في كل حالة.

تجربة عملية: Flagsmith مع OpenFeature

الآن سوف نشغل Flagsmith مع PostgreSQL، وننشئ مشروعاً لمتجر فيه Flag اسمه new_checkout لصفحة دفع جديدة، ثم نكتب تطبيق Node.js لا يعرف إلا OpenFeature، ونطلق الميزة لموظفي الشركة ثم ل 10% ثم 50%، ونطفئها بال Kill Switch، ونوقف خدمة ال Flags لنرى ماذا يحدث، وأخيراً نفحص ال Flags المنسية. وتحتاج التجربة إلى Docker فقط، والخدمة على 127.0.0.1 فقط.

تشغيل Flagsmith

الملف compose.yaml مبني على ملف Compose الرسمي في المستودع بعد تثبيت الإصدار والاستغناء عن خدمة المهام الخلفية في التجربة:

name: lab-devplat-flags

x-flagsmith-env: &flagsmith-env
  DATABASE_URL: postgresql://postgres:lab-db-pass@postgres:5432/flagsmith
  USE_POSTGRES_FOR_ANALYTICS: "true"
  ENVIRONMENT: production
  DJANGO_ALLOWED_HOSTS: "*"
  FLAGSMITH_DOMAIN: 127.0.0.1:48000
  DJANGO_SECRET_KEY: lab-secret-change-me
  ALLOW_REGISTRATION_WITHOUT_INVITE: "true"

services:
  postgres:
    image: postgres:17.11
    container_name: lab-devplat-flags-db
    environment:
      POSTGRES_PASSWORD: lab-db-pass
      POSTGRES_DB: flagsmith
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d flagsmith -U postgres"]
      interval: 2s
      retries: 30

  migrate:
    image: flagsmith/flagsmith:2.280.0
    container_name: lab-devplat-flags-migrate
    command: ["migrate"]
    environment: *flagsmith-env
    depends_on:
      postgres:
        condition: service_healthy

  flagsmith:
    image: flagsmith/flagsmith:2.280.0
    container_name: lab-devplat-flags-api
    command: ["serve"]
    environment: *flagsmith-env
    ports:
      - "127.0.0.1:48000:8000"
    depends_on:
      migrate:
        condition: service_completed_successfully

volumes:
  pgdata:
    name: lab-devplat-flags-pgdata

في الإعداد أعلاه لاحظ التالي:

  • الخدمة migrate تنشئ الجداول ثم تنتهي، والخدمة الأساسية لا تبدأ إلا بعد نجاحها بالشرط service_completed_successfully.
  • DJANGO_SECRET_KEY وكلمة مرور قاعدة البيانات هنا للتجربة، وفي الإنتاج تأتي من ملف أسرار وليس من الملف نفسه.
  • ALLOW_REGISTRATION_WITHOUT_INVITE يسمح بإنشاء أول حساب، وفي الإنتاج أغلق التسجيل بعد إنشاء حسابك وضع الخدمة خلف Reverse Proxy بشهادة TLS.

ثم نشغلها وننشئ الحساب والمشروع والبيئتين وال Flag ومفتاح السيرفر عبر API الإدارة (وتستطيع فعل الشيء نفسه من الواجهة):

docker compose up -d
F=http://127.0.0.1:48000
curl -s -X POST $F/api/v1/auth/users/ -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"Lab-Pass-2026-x","re_password":"Lab-Pass-2026-x","first_name":"Lab","last_name":"Admin"}'
H="Authorization: Token $ADMIN_TOKEN"   # the "key" returned above
j() { python3 -c "import json,sys;print(json.load(sys.stdin)$1)"; }   # read one field of the JSON reply
ORG=$(curl -s -X POST $F/api/v1/organisations/ -H "$H" -H 'Content-Type: application/json' -d '{"name":"Lab"}' | j "['id']")
PRJ=$(curl -s -X POST $F/api/v1/projects/ -H "$H" -H 'Content-Type: application/json' -d "{\"name\":\"shop\",\"organisation\":$ORG}" | j "['id']")
PROD=$(curl -s -X POST $F/api/v1/environments/ -H "$H" -H 'Content-Type: application/json' -d "{\"name\":\"production\",\"project\":$PRJ}" | j "['api_key']")
STG=$(curl -s -X POST $F/api/v1/environments/ -H "$H" -H 'Content-Type: application/json' -d "{\"name\":\"staging\",\"project\":$PRJ}" | j "['api_key']")
FEAT=$(curl -s -X POST $F/api/v1/projects/$PRJ/features/ -H "$H" -H 'Content-Type: application/json' \
  -d '{"name":"new_checkout","default_enabled":false,"description":"New checkout page, release toggle, remove after full rollout"}' | j "['id']")
SER=$(curl -s -X POST $F/api/v1/environments/$PROD/api-keys/ -H "$H" -H 'Content-Type: application/json' -d '{"name":"shop backend"}' | j "['key']")

والقيم التي حصلنا عليها كما يلي (أخفينا مفتاح السيرفر):

org 1
project 1
prod My5X6z3Bmi5avKN5ZCGZXY
staging DAD4YLV2XDGq87WMPZAVyg
feature 1
server-key ser.WeiJ…

لاحظ أن ال Flag يعرف مرة واحدة على مستوى المشروع، وقيمته وقواعده تختلف في كل بيئة، فتستطيع فتحه في staging وتركه مطفأ في الإنتاج. وفي وصف ال Flag كتبنا أنه Release Flag يحذف بعد الإطلاق، وهذا أول دفاع ضد Flag Debt.

المجموعات Segments: الموظفون ثم 10%

ننشئ مجموعتين: staff لمن صفته plan تساوي staff، وcheckout_rollout بشرط النسبة PERCENTAGE_SPLIT بقيمة 10، ثم نربط كل مجموعة بال Flag في بيئة الإنتاج كتجاوز Segment Override قيمته مفعل. وملف مجموعة النسبة كما يلي:

{ "name": "checkout_rollout", "project": 1,
  "rules": [ { "type": "ALL", "rules": [ { "type": "ANY", "conditions": [ { "operator": "PERCENTAGE_SPLIT", "property": null, "value": "10" } ] } ] } ] }

والصورة التالية من لوحة Flagsmith بعد الربط، وال Flag مطفأ للجميع ومفعل للمجموعتين:

نافذة Edit Feature new_checkout في Flagsmith على تبويب Segment Overrides، وفيها تجاوزان مفعلان: staff وcheckout_rollout، وخلفها قائمة ال Flags في بيئة production فيها new_checkout مطفأ وعليه علامة مجموعتين، وold_search مفعل
ال Flag مطفأ في البيئة، ومفعل للموظفين ولمجموعة النسبة فقط

التطبيق: OpenFeature فقط

ملف package.json بالإصدارات التي استخدمناها:

{
  "name": "shop-flags",
  "private": true,
  "type": "module",
  "dependencies": {
    "@openfeature/server-sdk": "1.23.0",
    "@openfeature/flagsmith-provider": "0.1.2",
    "flagsmith-nodejs": "6.2.0"
  }
}

والملف الوحيد الذي يعرف أننا نستخدم Flagsmith هو flags.mjs:

// The only file that knows which flag tool we use. The rest of the code talks to OpenFeature.
import { OpenFeature } from '@openfeature/server-sdk';
import { FlagsmithOpenFeatureProvider } from '@openfeature/flagsmith-provider';
import { Flagsmith } from 'flagsmith-nodejs';

const flagsmith = new Flagsmith({
  environmentKey: process.env.FLAGSMITH_SERVER_KEY,      // ser.… key: allows local evaluation
  apiUrl: process.env.FLAGSMITH_API_URL,                 // http://flagsmith:8000/api/v1/
  enableLocalEvaluation: true,                           // rules are fetched once, evaluated in-process
  environmentRefreshIntervalSeconds: 5,                  // poll for changes every 5 seconds
});

await OpenFeature.setProviderAndWait(new FlagsmithOpenFeatureProvider(flagsmith));
export const flags = OpenFeature.getClient('shop');
export const close = async () => { await OpenFeature.close(); flagsmith.close(); };  // stop the polling timer

في الكود أعلاه لاحظ أن flagsmith.close() ضروري في السكربتات القصيرة، فقد بقي سكربتنا الأول معلقاً بعد أن أنهى عمله، والسبب أن مؤقت التحديث كل 5 ثوان يبقي العملية حية. ونشغل التطبيق في Container على شبكة Flagsmith:

docker run -d --name lab-devplat-flags-app --network lab-devplat-flags_default -w /app \
  -e FLAGSMITH_SERVER_KEY=$SER -e FLAGSMITH_API_URL=http://flagsmith:8000/api/v1/ \
  node:22-bookworm-slim sleep infinity
docker cp app/. lab-devplat-flags-app:/app/
docker exec lab-devplat-flags-app npm install --no-audit --no-fund

10% ثم 50%: هل يبقى المستخدم في مكانه؟

السكربت rollout.mjs يقيم ال Flag لألف مستخدم، ويحفظ قائمة من فتحت لهم الميزة، وفي المرة التالية يتحقق هل بقوا ضمن القائمة الجديدة:

// How many of 1000 users get the new checkout? Run it again after changing the percentage.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { flags, close } from './flags.mjs';

const on = [];
for (let i = 1; i <= 1000; i++) {
  if (await flags.getBooleanValue('new_checkout', false, { targetingKey: `user-${i}` })) on.push(i);
}
console.log(`new_checkout ON for ${on.length} of 1000 users, first ones: ${on.slice(0, 5).map(i => 'user-' + i).join(' ')}`);

// Sticky: a user who had the feature at the previous percentage must still have it now
const file = '/tmp/last-rollout.json';
if (existsSync(file)) {
  const before = JSON.parse(readFileSync(file, 'utf8'));
  const kept = before.filter(i => on.includes(i)).length;
  console.log(`of the ${before.length} users who had it before, ${kept} still have it`);
}
writeFileSync(file, JSON.stringify(on));
await close();
$ docker exec lab-devplat-flags-app node rollout.mjs
new_checkout ON for 106 of 1000 users, first ones: user-16 user-19 user-27 user-46 user-47

ثم نرفع النسبة إلى 50 (من الواجهة أو بتعديل المجموعة عبر ال API) ونعيد التشغيل دون أي تغيير في التطبيق:

$ docker exec lab-devplat-flags-app node rollout.mjs
new_checkout ON for 481 of 1000 users, first ones: user-5 user-7 user-8 user-10 user-15
of the 106 users who had it before, 106 still have it

في المخرج أعلاه لاحظ التالي:

  • النسبة تقريبية، 106 و481 بدل 100 و500، وهذا طبيعي مع الهاش على عدد صغير من المستخدمين، ويقترب من النسبة كلما زاد العدد.
  • كل من كانت الميزة مفتوحة لهم عند 10% بقوا عند 50%، فلم يتبدل أحد بين صفحتي الدفع.
  • المستخدم user-7 لم يكن ضمن ال 10% وأصبح ضمن ال 50%، وسوف نتابعه في الخطوة التالية.

لماذا رأى المستخدم هذه القيمة؟

السكربت details.mjs يضيف Hook يكتب كل تقييم مع سببه، ثم يقيم لمستخدم عادي ولموظف، ثم يطلب Flag باسم فيه خطأ إملائي:

// Why did this user get this value? Evaluation details and a logging hook.
import { flags, close } from './flags.mjs';

flags.addHooks({
  after(hookContext, details) {
    console.log(`[flag] ${details.flagKey} user=${hookContext.context.targetingKey} value=${details.value} reason=${details.reason}`);
  },
});

await flags.getBooleanDetails('new_checkout', false, { targetingKey: 'user-7' });
await flags.getBooleanDetails('new_checkout', false, { targetingKey: 'staff-1', traits: { plan: 'staff' } });
const missing = await flags.getBooleanDetails('new_chekout', false, { targetingKey: 'user-7' });
console.log('typo in flag key ->', JSON.stringify({ value: missing.value, reason: missing.reason, errorCode: missing.errorCode }));
await close();

والمخرج عند نسبة 10% كان كما يلي:

[flag] new_checkout user=user-7 value=false reason=DISABLED
[flag] new_checkout user=staff-1 value=true reason=TARGETING_MATCH
typo in flag key -> {"value":false,"reason":"ERROR","errorCode":"FLAG_NOT_FOUND"}

وعند 50% أصبح السطر الأول user=user-7 value=true reason=TARGETING_MATCH. ولاحظ السطر الأخير: الخطأ الإملائي في اسم ال Flag لا يكسر التطبيق، وإنما يعيد القيمة الافتراضية بصمت، فإذا كتبت الاسم خطأ في الكود فلن تظهر الميزة أبداً ولن يخبرك أحد، إلا إذا سجلت الأخطاء في ال Hook أو فحصت الأسماء في CI كما سنفعل.

ال Kill Switch أثناء عمل التطبيق

السكربت watch.mjs يشبه سيرفراً يعمل، فيطبع كل ثانيتين ما يراه user-7 والموظف staff-1. وبعد 9 ثوان من تشغيله أطفأنا التجاوزين من ال API، وهو ما يعادل زر Disable All في الواجهة:

18:17:46 kill switch
featurestate 4 enabled = False
featurestate 3 enabled = False
t= 2s  user-7=true  staff-1=true
t= 4s  user-7=true  staff-1=true
t= 6s  user-7=true  staff-1=true
t= 8s  user-7=true  staff-1=true
t=10s  user-7=false  staff-1=false
t=12s  user-7=false  staff-1=false
t=14s  user-7=false  staff-1=false

اختفت الميزة من التطبيق خلال الدورة التالية لتحديث القواعد، أي خلال ثوان، ودون إعادة تشغيل أو نشر. وهذا هو ال Kill Switch الذي لا يحققه متغير البيئة.

نكسرها: خدمة ال Flags متوقفة

أعدنا تفعيل التجاوزين، وشغلنا watch.mjs، ثم أوقفنا Container الخدمة بعد 7 ثوان:

18:18:37 flag service stopped
t= 2s  user-7=true  staff-1=true
t= 4s  user-7=true  staff-1=true
t= 6s  user-7=true  staff-1=true
t= 8s  user-7=true  staff-1=true
t=10s  user-7=true  staff-1=true
t=12s  user-7=true  staff-1=true
t=14s  user-7=true  staff-1=true
t=16s  user-7=true  staff-1=true
t=18s  user-7=true  staff-1=true

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

{"level":50, … ,"err":{"type":"TypeError","message":"fetch failed: getaddrinfo ENOTFOUND flagsmith", … },"msg":"updateEnvironment failed"}
file:///app/node_modules/flagsmith-nodejs/build/esm/sdk/index.js:152
                throw new Error('getEnvironmentFlags failed and no default flag handler was provided', { cause: error });
                      ^

Error: getEnvironmentFlags failed and no default flag handler was provided
    at Flagsmith.getEnvironmentFlags (file:///app/node_modules/flagsmith-nodejs/build/esm/sdk/index.js:152:23)
    at async FlagsmithOpenFeatureProvider.initialize (/app/node_modules/@openfeature/flagsmith-provider/index.cjs.js:167:9) {
Node.js v22.23.3
exit 1

التطبيق كله رفض أن يبدأ لأن خدمة ال Flags متوقفة، أي أن أداة صممت لتقليل المخاطر أصبحت نقطة تعطل للمتجر كله. والسبب أن setProviderAndWait ينتظر تهيئة ال Provider، والمكتبة ترمي خطأ عندما لا تجد القواعد ولا يوجد معالج للقيم الافتراضية. وجربنا أولاً أن نلتقط الخطأ ونكمل، فبدأ التطبيق لكن التقييم الأول بقي معلقاً أكثر من دقيقة بسبب إعادة المحاولات ومهلة الطلب الافتراضية البالغة 10 ثوان. والإصلاح الصحيح ثلاثة أسطر في flags.mjs:

import { Flagsmith, DefaultFlag } from 'flagsmith-nodejs';

const flagsmith = new Flagsmith({
  environmentKey: process.env.FLAGSMITH_SERVER_KEY,      // ser.… key: allows local evaluation
  apiUrl: process.env.FLAGSMITH_API_URL,                 // http://flagsmith:8000/api/v1/
  enableLocalEvaluation: true,                           // rules are fetched once, evaluated in-process
  environmentRefreshIntervalSeconds: 5,                  // poll for changes every 5 seconds
  requestTimeoutSeconds: 2,                              // do not hold a request for 10 s (the default)
  retries: 1,
  defaultFlagHandler: () => new DefaultFlag(undefined, false),   // service unreachable: every flag is off
});
{"value":false,"reason":"ERROR","errorCode":"FLAG_NOT_FOUND","errorMessage":"Flag 'new_checkout' was not found."}

real	0m16.567s

الآن يبدأ التطبيق والخدمة متوقفة، وكل ال Flags على القيمة الافتراضية الآمنة، أي صفحة الدفع القديمة. ولاحظ أن التشغيل استغرق 16 ثانية بسبب محاولات الاتصال الفاشلة، لذلك اجعل فحص الجاهزية Readiness في تطبيقك يتحمل هذه المدة، وراقب خدمة ال Flags كما تراقب قاعدة البيانات، فهي الآن جزء من الإنتاج. وبعد إعادة تشغيل الخدمة عاد كل شيء كما كان دون تدخل.

اختبار المسارين دون خدمة ال Flags

كل Flag يعني مسارين يجب أن يعملا، والاختبارات لا يجوز أن تعتمد على خدمة حقيقية. وOpenFeature يأتي بمزود في الذاكرة InMemoryProvider، فنكتب اختباراً يشغل المسارين:

// Unit tests run both paths of the flag without Flagsmith: OpenFeature's in-memory provider.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk';

const checkoutPage = async (client, userId) =>
  (await client.getBooleanValue('new_checkout', false, { targetingKey: userId })) ? 'checkout-v2' : 'checkout-v1';

for (const variant of ['on', 'off']) {
  test(`checkout with new_checkout ${variant}`, async () => {
    await OpenFeature.setProviderAndWait(new InMemoryProvider({
      new_checkout: { variants: { on: true, off: false }, defaultVariant: variant, disabled: false },
    }));
    const page = await checkoutPage(OpenFeature.getClient(), 'user-7');
    assert.equal(page, variant === 'on' ? 'checkout-v2' : 'checkout-v1');
  });
}
$ docker exec lab-devplat-flags-app node --test checkout.test.mjs
# tests 2
# pass 2
# fail 0

وقد شرحنا أنواع الاختبارات ومكان كل منها في أنواع الاختبارات.

فحص ال Flags المنسية في CI

أضفنا إلى المشروع Flag قديماً اسمه old_search أطلق للجميع ولم يحذف، ثم كتبنا سكربتاً يقارن ال Flags في Flagsmith بال Flags التي يقرؤها الكود:

// Flag debt check for CI: flags on the server that the code no longer reads, and flags the code reads that do not exist.
import { readdirSync, readFileSync } from 'node:fs';

const api = process.env.FLAGSMITH_API_URL, project = process.env.FLAGSMITH_PROJECT;
const res = await fetch(`${api}projects/${project}/features/`, { headers: { Authorization: `Token ${process.env.FLAGSMITH_ADMIN_TOKEN}` } });
const server = new Map((await res.json()).results.map(f => [f.name, f.created_date.slice(0, 10)]));

const inCode = new Set();
for (const file of readdirSync('.').filter(f => f.endsWith('.mjs'))) {
  for (const m of readFileSync(file, 'utf8').matchAll(/get(?:Boolean|String|Number|Object)(?:Value|Details)\('([\w-]+)'/g)) inCode.add(m[1]);
}
for (const [name, created] of server) if (!inCode.has(name)) console.log(`STALE    ${name} (created ${created}): delete it from Flagsmith`);
for (const name of inCode) if (!server.has(name)) console.log(`MISSING  ${name}: typo, or the flag was deleted while the code still reads it`);
process.exitCode = [...server.keys()].some(n => !inCode.has(n)) || [...inCode].some(n => !server.has(n)) ? 1 : 0;
$ docker exec -e FLAGSMITH_PROJECT=1 -e FLAGSMITH_ADMIN_TOKEN=… lab-devplat-flags-app node flag-audit.mjs; echo "exit code: $?"
STALE    old_search (created 2026-10-07): delete it from Flagsmith
MISSING  new_chekout: typo, or the flag was deleted while the code still reads it
exit code: 1

كشف السكربت ال Flag المنسي، وكشف الخطأ الإملائي الذي تركناه عمداً في details.mjs، وأنهى بالرمز 1 فيفشل خط CI حتى يصلحهما أحد. وفي مشروع حقيقي يبحث السكربت في كل ملفات الكود، ويستخدم توكناً للقراءة فقط من أسرار CI كما شرحنا في خط CI/CD آمن.

التنظيف

docker rm -f lab-devplat-flags-app
docker compose -p lab-devplat-flags down -v

أخطاء شائعة

  • Flag دون تاريخ حذف: كل Release Flag يولد مع مالك وتذكرة لحذفه، وإلا أصبح جزءاً من Flag Debt.
  • إعادة استخدام اسم Flag قديم: احذف ال Flag القديم مع كوده، واستخدم اسماً جديداً لكل ميزة.
  • قيمة افتراضية تفتح الميزة: إذا تعطلت الخدمة أو أخطأت في الاسم فالقيمة الافتراضية هي التي تعمل، فاجعلها المسار القديم المجرب.
  • تطبيق لا يبدأ إذا توقفت خدمة ال Flags: جرب ذلك بنفسك قبل أن يحدث في الإنتاج كما فعلنا.
  • اختيار عشوائي في كل طلب بدل الهاش: المستخدم يتنقل بين المسارين وتفسد المقارنة بين المجموعتين.
  • مفتاح السيرفر في كود المتصفح: مفتاح ser. في Flagsmith يكشف كل القواعد والمجموعات، ومكانه السيرفر.
  • ال Flag بدل الصلاحيات: إخفاء زر عبر Flag لا يمنع أحداً من استدعاء ال API خلفه، فالصلاحية تفحص على السيرفر دائماً.

قائمة تحقق قبل أن تعتمد ال Feature Flags في فريقك

  • الكود يستدعي OpenFeature فقط، وملف واحد يعرف الأداة.
  • كل استدعاء له قيمة افتراضية آمنة، والتطبيق يبدأ والخدمة متوقفة.
  • القيمة والسبب يسجلان مع كل طلب، والأخطاء مثل FLAG_NOT_FOUND تظهر في المراقبة.
  • الاختبارات تشغل المسارين بمزود في الذاكرة.
  • فحص في CI يكشف ال Flags المنسية والأسماء الخاطئة.
  • خدمة ال Flags لها نسخ احتياطي ومراقبة وصلاحيات، وتسجيل الحسابات مغلق.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • ال Flag يفصل النشر عن الإطلاق، فينشر الإصدار مرة واحدة وتفتح الميزة بعد ذلك خطوة بخطوة، وال Kill Switch يطفئها في ثوان دون نشر.
  • متغير البيئة يكفي في البداية، ومع النسب والمجموعات والسجل تحتاج إلى خدمة Flags، والتقييم المحلي يجعل التطبيق سريعاً ويحتفظ بآخر القواعد إذا تعطلت الخدمة.
  • الإطلاق التدريجي يعتمد على هاش ثابت لمعرف المستخدم، فرفع النسبة من 10% إلى 50% أبقى في تجربتنا كل ال 106 مستخدمين ضمن من يرون الميزة.
  • القيمة الافتراضية هي خطة الطوارئ، وقد رفض تطبيقنا أن يبدأ عندما توقفت خدمة ال Flags حتى أضفنا معالج القيم الافتراضية ومهلة قصيرة.
  • OpenFeature يفصل كودك عن الأداة، وكل Release Flag يحذف بعد الإطلاق، وفحص في CI يكشف المنسي منها.
  • وفي مقالات قادمة بإذن الله من مرحلة منصات للمطورين نشرح تثبيت Unleash وFlagsmith للفريق، واختبارات A/B مع GrowthBook، ثم المقارنة بينها.
دليلTrunk-Based Development: فروع قصيرة ودمج يوميلماذا تنفجر الفروع الطويلة يوم الإصدار، وكيف يدمج الفريق عمله في main كل يوم بأمان مع CI وFeature Flags.دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.دليلطرق نشر التطبيقات من FTP إلى GitOps، وأين تقف أنتكيف تطور نشر التطبيقات عبر السنين، وما الذي يتعطل في كل طريقة، وكيف تعرف أين تقف وما خطوتك التالية.دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ تجربة Flagsmith 2.280.0 مع PostgreSQL 17.11 وتطبيق Node.js 22 يستخدم OpenFeature Server SDK 1.23.0 ومزود Flagsmith 0.1.2.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.