في مقال التطوير على الفرع الرئيسي عرفنا ال 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 مطفأ، ثم تفتح الميزة لفريقك، ثم لنسبة صغيرة، ثم تزيدها، دون أن تبني أو تنشر شيئاً جديداً بين الخطوات، كما تبين الصورة التالية:

وقد يتساءل البعض: أليس هذا هو الكناري 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 لمستخدم معين، والصورة التالية تبين الطريقة التي استخدمناها في التجربة:

وفي الصورة أعلاه لاحظ التالي:
- سياق التقييم 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 على معرف المستخدم، كما تبين الصورة التالية:

في 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. والصورة التالية تبين أجزاءه:

- واجهة التقييم 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 الصحيحة وما يحدث عند كسرها:

والحل عملي وبسيط: كل 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 مطفأ للجميع ومفعل للمجموعتين:

التطبيق: 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-fund10% ثم 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.