تنشر إصداراً جديداً من تطبيقك في المتجر، وبعد يومين يصلك تقييم بنجمة واحدة: «التطبيق يقفل عند الدفع»، ولا شيء غير ذلك، فلا تعرف نوع الجوال ولا نسخة النظام ولا الخطوة التي سبقت المشكلة، وعند تجربتك على جوالك يعمل كل شيء. وفي تطبيقات الويب كان الحل أداة تتبع الأخطاء Error Tracking التي شرحناها في دليل GlitchTip، لأن الخطأ يحدث على سيرفرك أو في متصفح يستطيع أن يرسله فوراً، وأما في الجوال فالعطل يحدث على جهاز مستخدم لا تعرفه، والتطبيق نفسه يتوقف في لحظة العطل، والكود الذي وصله المستخدم مضغوط ومغيرة أسماؤه، فيصلك ال Stack Trace بأسماء مثل a.b.a لا تدل على شيء.
وهذا هو عمل تقارير الأعطال Crash Reporting في تطبيقات الجوال: أن يصل العطل من جهاز المستخدم إليك، وأن يصبح مقروءاً، وأن تعرف كم مستخدماً تأثر به وفي أي إصدار، وأن تقرر بناءً على ذلك هل توقف الإصدار الجديد قبل أن يصل إلى الجميع. وفي هذا المقال سوف نتناول:
- كيف يصل العطل من جوال المستخدم إليك، ولماذا يرسل التقرير في التشغيل التالي وليس لحظة العطل.
- أنواع الأعطال: ال Crash وال ANR وتعليق التطبيق App Hang والأخطاء غير القاتلة Non-fatal، وإنهاء التطبيق بسبب الذاكرة Out of Memory أو ال Watchdog في iOS، وأعطال الكود الأصلي Native في NDK.
- استعادة الأسماء والأسطر Symbolication: ملف ال Mapping في Android، وال dSYM في iOS، ورموز مكتبات NDK، مع تجربة حقيقية في Docker تعيد Stack Trace مشفراً إلى أصله، وماذا يحدث إذا استخدمت ملف Mapping من بناء آخر.
- مقاييس الاستقرار: نسبة المستخدمين دون أعطال Crash-free Users ونسبة الجلسات Crash-free Sessions، وحدود Android vitals في Google Play، وXcode Organizer.
- السياق الذي يجعل التقرير مفيداً: ال Breadcrumbs ورقم الإصدار، ثم الإطلاق التدريجي Staged Rollout ومتى توقفه.
- ما الذي لا يجب أن يخرج من التطبيق في تقرير العطل، وما يقوله نظام حماية البيانات الشخصية في المملكة.
والمواضيع في هذه السلسلة:
- مفاهيم تقارير الأعطال في تطبيقات الجوال (الموضوع الحالي).
- أدوات تقارير أعطال الجوال: Firebase Crashlytics والخدمات التجارية والبدائل مفتوحة المصدر.
- مختبر عملي: أعطال تطبيق Android تصل إلى سيرفرك مع قراءة ال Stack Trace.
كيف يصل العطل Crash من جوال المستخدم إليك؟
أداة تقارير الأعطال مكونة من جزأين: مكتبة SDK تضيفها إلى التطبيق، وخدمة Backend تستقبل التقارير وتجمعها وتعرضها، والصورة التالية تبين المسار من لحظة العطل إلى التنبيه الذي يصلك:

وفيما يلي الخطوات بالتفصيل:
- تسجيل معالج العطل Crash Handler: عند تشغيل التطبيق تسجل المكتبة نفسها لتستقبل كل استثناء Exception لم يعالجه الكود، ففي Java وKotlin تستخدم
Thread.setDefaultUncaughtExceptionHandler، وفي الكود الأصلي C/C++ تسجل معالجاً للإشارات Signals مثلSIGSEGVوSIGABRT، وفي iOS تلتقط استثناءات Mach والإشارات واستثناءات Objective-C وSwift. - كتابة التقرير على القرص: عند العطل تجمع المكتبة ال Stack Trace لكل Thread ومعلومات الجهاز والإصدار وآخر الأحداث، وتكتبها في ملف داخل مساحة التطبيق.
- توقف التطبيق: النظام ينهي العملية Process كما كان سيحدث دون المكتبة، ويرى المستخدم التطبيق يغلق.
- الإرسال في التشغيل التالي: عندما يفتح المستخدم التطبيق مرة أخرى تجد المكتبة التقرير المحفوظ وترسله إلى الخدمة ثم تحذفه.
- التجميع واستعادة الأسماء: الخدمة تجمع التقارير المتشابهة في مشكلة واحدة Issue، وتحول العناوين والأسماء المشفرة إلى أسماء الدوال والملفات والأسطر الأصلية بملفات الرموز التي رفعتها عند البناء، ثم تحسب المقاييس وترسل التنبيهات.
وقد يتساءل البعض: لماذا لا ترسل المكتبة التقرير لحظة العطل؟ والإجابة أن العملية في تلك اللحظة في حالة غير مستقرة، فالذاكرة قد تكون تالفة، والإشارة قد تكون وصلت في منتصف تنفيذ دالة تحجز الذاكرة، ومعالج الإشارات لا يسمح إلا بعدد قليل من الدوال الآمنة Async-signal-safe، والنظام قد ينهي العملية بعد أجزاء من الثانية، ولذلك تكتب المكتبة أقل ما يمكن على القرص وتترك الشبكة للتشغيل التالي. وهذا يعني أمرين عملياً: التقرير يتأخر حتى يفتح المستخدم التطبيق مرة أخرى، وإذا تعطل التطبيق عند بدء التشغيل Startup Crash قبل أن تجهز المكتبة فقد لا يصلك شيء أبداً، وهذه أسوأ الأعطال على الإطلاق، لأن المستخدم لا يستطيع فتح التطبيق، وأنت لا ترى شيئاً في اللوحة. لذلك هيئ مكتبة تقارير الأعطال في أول سطر من تشغيل التطبيق، قبل أي مكتبة أخرى.
ليس كل خروج للتطبيق Crash: أنواع الأعطال
المستخدم يرى شيئاً واحداً، وهو أن التطبيق أغلق أو توقف عن الاستجابة، ولكن السبب الفعلي قد يكون واحداً من عدة أنواع تختلف في طريقة التقاطها وفي ما يصلك منها، والصورة التالية تبين الأنواع الرئيسية:

| النوع | ماذا يحدث | هل يصلك Stack Trace؟ | من يلتقطه |
|---|---|---|---|
| العطل Crash في Java/Kotlin أو Swift | استثناء لم يعالجه الكود فأنهى العملية | نعم، كاملاً | مكتبة تقارير الأعطال، ونظام التشغيل |
| عطل الكود الأصلي Native Crash | إشارة مثل SIGSEGV في مكتبة C/C++ | نعم، عناوين تحتاج ملفات رموز | المكتبة إذا كان فيها جزء Native، ونظام التشغيل |
| عدم الاستجابة ANR في Android | ال Main Thread متوقف 5 ثوان عن الرد على لمسة أو زر | نعم، Stack Trace لل Threads وقت الحادثة | النظام، والمكتبة في التشغيل التالي |
| تعليق التطبيق App Hang في iOS | ال Main Thread متوقف فلا تستجيب الواجهة | بحسب الأداة والمدة | MetricKit، والمكتبة |
| إنهاء بسبب الذاكرة Out of Memory | النظام ينهي التطبيق لأنه استهلك ذاكرة أكثر من المسموح | لا | النظام فقط، والمكتبة تستنتجه في التشغيل التالي |
| إنهاء ال Watchdog في iOS | التطبيق تأخر في التشغيل أو في الاستجابة فأنهاه النظام | يصلك تقرير من Apple، والمكتبة تستنتجه | النظام، والمكتبة بالاستنتاج |
| الخطأ غير القاتل Non-fatal | استثناء عالجه الكود ثم سجله المطور | نعم | المكتبة، عندما تستدعيها أنت |
عدم الاستجابة ANR وتعليق التطبيق App Hang
كل ما تراه على الشاشة يرسمه Thread واحد هو ال Main Thread، وهو نفسه الذي يستقبل اللمسات، فإذا وضعت عليه طلب شبكة متزامناً Synchronous، أو قراءة ملف JSON كبير، أو استعلاماً ثقيلاً من قاعدة البيانات، فإن الواجهة تتجمد حتى ينتهي. وفي Android يسمي النظام هذه الحالة «التطبيق لا يستجيب» Application Not Responding واختصاراً ANR، وبحسب صفحة ANR في توثيق Android فإن أشهر أسبابها أن التطبيق لم يرد على حدث إدخال مثل لمسة أو زر خلال 5 ثوان، ومن أسبابها أيضاً BroadcastReceiver لم ينته في وقته، وخدمة Service بدأت في المقدمة Foreground ولم تستدع startForeground خلال 5 ثوان. وعند حدوثها في المقدمة يعرض النظام نافذة تسمح للمستخدم بإغلاق التطبيق، وأغلب المستخدمين يغلقونه فعلاً، ولذلك يحسبها Google Play عطلاً موازياً لل Crash كما سوف نرى في Android vitals.
وفي iOS لا توجد نافذة ANR، ولكن المشكلة نفسها تسمى تعليق التطبيق App Hang، وإذا طالت أثناء التشغيل أو العودة من الخلفية ينهي النظام التطبيق، وهذا ما يسمى إنهاء ال Watchdog كما في الفقرة التالية.
إنهاء التطبيق بسبب الذاكرة Out of Memory وال Watchdog في iOS
هناك حالات يغلق فيها التطبيق دون أن يتعطل الكود نفسه، لأن نظام التشغيل هو الذي قرر إنهاءه، وأشهرها حالتان:
- الذاكرة Out of Memory: عندما تنخفض الذاكرة المتاحة يطلب iOS من التطبيقات تحرير ما تستطيع، فإذا لم يكف ذلك أنهى بعضها، وتسمي Apple هذا الحدث Jetsam. وبحسب صفحة تقارير Jetsam فإن تقريره بصيغة JSON ويحتوي على استهلاك الذاكرة لكل العمليات في الجهاز، ولا يحتوي على Stack Trace لأي Thread في تطبيقك، وإذا أنهى النظام تطبيقك وهو ظاهر على الشاشة فسوف يبدو للمستخدم كأنه Crash. وفي التقرير حقل
reasonيبين السبب، مثلper-process-limitعندما يتجاوز تطبيقك الحد المسموح لعملية واحدة. وفي Android يقوم بالعمل نفسه ال Low Memory Killer. - ال Watchdog: يراقب النظام زمن التشغيل وسرعة الاستجابة، وينهي التطبيق الذي يحجز ال Main Thread مدة طويلة، ويظهر ذلك في التقرير بالرمز
0x8badf00dكما في صفحة إنهاء ال Watchdog. والمثال الذي تعرضه Apple في الصفحة نفسها لتطبيق لم يرسم أول شاشة بعد التشغيل:
Termination Reason: Namespace SPRINGBOARD, Code 0x8badf00d
Termination Description: SPRINGBOARD,
scene-create watchdog transgression: application<com.example.MyCoolApp>:667
exhausted real (wall clock) time allowance of 19.97 secondsفي التقرير أعلاه لاحظ التالي:
scene-createتعني أن التطبيق لم يرسم أول إطار من واجهته في الوقت المسموح، وأماscene-updateفتعني أن الواجهة لم تتحدث لأن ال Main Thread مشغول.- الوقت المسموح هنا قرابة 20 ثانية لأول شاشة، والسبب الأشهر عمل متزامن عند التشغيل مثل طلب شبكة أو ترحيل Migration لقاعدة بيانات كبيرة.
- ال Stack Trace في هذا التقرير يبين ما كان يفعله التطبيق لحظة إنهائه، وليس بالضرورة الكود الذي استهلك الوقت، فقد تكون دالة أخذت 4 ثوان وانتهت قبل ذلك.
والنتيجة المهمة لهذين النوعين أن مكتبة تقارير الأعطال داخل التطبيق لا تستطيع التقاطهما لحظة حدوثهما، لأن النظام ينهي العملية بإشارة SIGKILL التي لا يمكن اعتراضها، فتلجأ المكتبات إلى الاستنتاج في التشغيل التالي: إذا كان التطبيق في المقدمة آخر مرة، ولم يحفظ تقرير Crash، ولم يغلقه المستخدم، ولم يتحدث النظام أو التطبيق، فالأرجح أن النظام أنهاه. ولذلك يختلف عدد هذه الحالات بين أداة وأخرى، ولا تقارن رقم OOM بين أداتين مختلفتين.
وفي Android منذ الإصدار 11 (API 30) أصبح هذا الاستنتاج أدق، لأن الواجهة ApplicationExitInfo تعطي التطبيق في التشغيل التالي سبب انتهاء العملية السابقة كما سجله النظام، ومن قيمها:
REASON_CRASH: استثناء لم يعالج في كود Java.REASON_CRASH_NATIVE: عطل في الكود الأصلي.REASON_ANR: أنهى النظام التطبيق لأنه لا يستجيب.REASON_LOW_MEMORY: أنهاه ال Low Memory Killer لأن الجهاز تحت ضغط الذاكرة، والتوثيق ينبه إلى أن بعض الأجهزة لا تدعم هذا السبب.REASON_USER_REQUESTED: المستخدم أوقف التطبيق من الإعدادات أو أزاله من قائمة التطبيقات الأخيرة، وهذا ليس عطلاً.
وفي iOS يقدم إطار MetricKit شيئاً قريباً، فهو يسلم التطبيق تقارير تشخيص Diagnostics عن الأعطال وتعليق التطبيق واستهلاك المعالج والكتابة على القرص، وتصل هذه التقارير فوراً منذ iOS 15، وأما المقاييس فتصل مرة في اليوم على الأكثر عن الساعات الأربع والعشرين السابقة.
الأخطاء غير القاتلة Non-fatal
عندما يعالج الكود استثناءً بـ try/catch ويعرض للمستخدم رسالة «حدث خطأ، حاول مرة أخرى»، فالتطبيق لم يغلق، ولكن المستخدم لم يكمل الدفع، وهذه الحالة لا تصل إلى أي أداة إلا إذا سجلتها بنفسك، وكل الأدوات توفر دالة لذلك مثل recordException. والقاعدة هنا أن تسجل الاستثناء الذي يعني أن المستخدم لم يحصل على ما يريد، مثل فشل الدفع أو فشل تحميل السلة، ولا تقم بتسجيل كل استثناء متوقع مثل انقطاع الشبكة في المترو، والسبب أن الأخطاء المتوقعة تغطي على الأخطاء الحقيقية في اللوحة، وبعض الأدوات تحتفظ بعدد محدود منها في كل جلسة.
أعطال الكود الأصلي Native في NDK
إذا كان تطبيقك أو إحدى مكتباته تستخدم كود C/C++ عبر ال NDK، وهذا أكثر مما تتوقع، فمكتبات معالجة الصور والفيديو وقواعد البيانات المحلية والألعاب وبعض SDK الدفع فيها كود أصلي، فإن العطل هنا لا يكون استثناءً بل إشارة من النظام مثل SIGSEGV عند قراءة عنوان غير صالح في الذاكرة، أو SIGABRT عند استدعاء abort(). وبحسب صفحة الأعطال في توثيق Android يكتب النظام في هذه الحالة تقريراً يبدأ بالإشارة وعنوان الخطأ ثم قائمة backtrace فيها لكل إطار Frame عنوان pc داخل ملف المكتبة .so. ومعالج الاستثناءات في Java لا يرى هذه الأعطال إطلاقاً، ولذلك تحتاج الأداة إلى جزء Native خاص في ال SDK، فإذا لم تفعله فإن هذه الأعطال لا تظهر في لوحتك، مع أنها تظهر في Android vitals وتحسب عليك.
لماذا يصلك ال Stack Trace غير مقروء؟ استعادة الأسماء Symbolication
الكود الذي تكتبه ليس الكود الذي يصل إلى المستخدم، ففي Android يقوم ال R8 عند بناء نسخة الإصدار Release بتصغير الكود Shrinking وتغيير أسماء الكلاسات والدوال Obfuscation ودمج الدوال الصغيرة في من يستدعيها Inlining، وهذا يقلل حجم التطبيق ويصعب الهندسة العكسية Reverse Engineering، وفي iOS وفي مكتبات NDK يخرج المترجم Compiler كوداً آلياً Machine Code لا يحمل أسماء الدوال ولا أرقام الأسطر، وتوضع هذه المعلومات في ملف منفصل. لذلك فإن ال Stack Trace الذي يصلك من جهاز المستخدم يحتوي على أسماء مختصرة أو عناوين في الذاكرة، وتحويلها إلى أسماء الدوال والملفات والأسطر الأصلية يسمى Symbolication، وفي Android يسمى أيضاً إزالة التشفير Deobfuscation أو Retrace.

ولكل منصة ملف رموز Symbol File خاص بها، وفيما يلي أشهرها:
| المنصة | ملف الرموز | أين تجده | الأداة الرسمية لقراءته |
|---|---|---|---|
| Android (Java/Kotlin مع R8) | mapping.txt | app/build/outputs/mapping/<variant>/ | retrace في أدوات سطر الأوامر |
| Android NDK (C/C++) | مكتبات .so غير مجردة Unstripped | build/intermediates/cxx/…/obj/<abi> | ndk-stack |
| iOS | ملف dSYM لكل Binary | داخل ال Xcode Archive | Xcode وأدوات atos |
| Flutter | ملفات .symbols لكل معمارية | المجلد الذي تحدده في --split-debug-info | flutter symbolize |
وفي iOS تقول صفحة بناء التطبيق مع معلومات التصحيح إن كل ملف Binary في التطبيق، أي التطبيق نفسه وكل Framework وكل Extension، له ملف dSYM خاص به، وإن الربط بينهما بمعرف البناء Build UUID، فإذا بنيت الكود نفسه مرتين بإصدار Xcode مختلف أو إعدادات مختلفة فلن يتطابق المعرف، ولن يقرأ ملف dSYM من البناء الأول أعطال البناء الثاني. ولذلك تطلب Apple أن تتأكد من أن الإعداد DEBUG_INFORMATION_FORMAT قيمته DWARF with dSYM File قبل بناء نسخة التوزيع، وأن تحتفظ بال Xcode Archive لكل إصدار توزعه. وفي Flutter يكون التصغير بالأمر flutter build مع --obfuscate و--split-debug-info، وملفات الرموز تخرج لكل معمارية مثل app.android-arm64.symbols، وفي React Native تحتاج إلى ملفين معاً: خريطة المصدر Source Map لكود JavaScript، وملفات الرموز للجزء الأصلي في Android وiOS.
والآن سوف نرى ذلك عملياً. التجربة التالية تعمل على أي جهاز فيه Docker، دون Android SDK ودون جهاز أو محاكي Emulator، والسبب أن R8 نفسه برنامج Java يستطيع أن يعالج ملفات Java العادية بالخيار --classfile، فنحصل على الأثر نفسه الذي يحدث لتطبيق Android: أسماء مغيرة، ودوال مدمجة، وملف mapping.txt، ثم نشغل البرنامج على ال JVM ليتعطل فعلاً، ونقرأ ال Stack Trace المشفر بأداة Retrace التي تأتي مع R8.
تجربة: Stack Trace مشفر Obfuscated من R8 وإعادته إلى أصله
لنفرض أن لديك شاشة دفع تحسب المجموع بعد الخصم، والكود الذي يحسب نسبة الخصم لا يتوقع أن يكون رمز الخصم فارغاً، والملفات التالية هي البرنامج كاملاً، وكلها في المجلد src/com/example/shop/:
// Main.java
package com.example.shop;
public class Main {
public static void main(String[] args) {
CheckoutScreen screen = new CheckoutScreen();
screen.onPayClicked(args.length > 0 ? args[0] : null);
}
}
// CheckoutScreen.java
package com.example.shop;
public class CheckoutScreen {
private final CartRepository repository = new CartRepository();
public void onPayClicked(String couponCode) {
Cart cart = repository.loadCart();
String total = PriceFormatter.format(cart.totalAfterCoupon(couponCode));
System.out.println("Total: " + total);
}
}
// Cart.java
package com.example.shop;
public class Cart {
private final long subtotalHalalas;
public Cart(long subtotalHalalas) {
this.subtotalHalalas = subtotalHalalas;
}
public long totalAfterCoupon(String couponCode) {
int percent = CouponRules.discountPercent(couponCode);
return subtotalHalalas - (subtotalHalalas * percent / 100);
}
}
// CouponRules.java
package com.example.shop;
public class CouponRules {
public static int discountPercent(String couponCode) {
if (couponCode.startsWith("VIP")) {
return 20;
}
return 0;
}
}
// CartRepository.java
package com.example.shop;
public class CartRepository {
public Cart loadCart() {
return new Cart(15000);
}
}
// PriceFormatter.java
package com.example.shop;
public class PriceFormatter {
public static String format(long halalas) {
return (halalas / 100) + "." + String.format("%02d", halalas % 100) + " SAR";
}
}وملف قواعد R8 بصيغة ProGuard، وهو نفسه الذي تضعه في proguard-rules.pro في مشروع Android:
-keep class com.example.shop.Main {
public static void main(java.lang.String[]);
}
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFileوسكربت البناء build.sh، ويحتاج إلى ملف r8.jar من مستودع Google Maven، وقد استخدمنا الإصدار 9.5.28:
set -e
cd /work
rm -rf classes out && mkdir classes out
javac --release 17 -d classes src/com/example/shop/*.java
jar --create --file out/app.jar -C classes .
java -cp r8.jar com.android.tools.r8.R8 --classfile --release \
--lib "$JAVA_HOME" --pg-conf rules.pro \
--pg-map-output out/mapping.txt --output out/app-release.jar out/app.jarالآن نقوم بتشغيل البناء داخل Container فيه JDK 21، وفي Git Bash على Windows أضف MSYS_NO_PATHCONV=1 قبل الأمر واستخدم "$(pwd -W)" بدلاً من "$(pwd)":
curl -fLo r8.jar https://dl.google.com/android/maven2/com/android/tools/r8/9.5.28/r8-9.5.28.jar
docker run --rm -v "$(pwd)":/work eclipse-temurin:21.0.8_9-jdk bash /work/build.shثم نشغل النسخة العادية، ثم نسخة الإصدار التي خرجت من R8، دون رمز خصم في الحالتين:
docker run --rm -v "$(pwd)":/work -w /work eclipse-temurin:21.0.8_9-jdk java -cp out/app.jar com.example.shop.Main
docker run --rm -v "$(pwd)":/work -w /work eclipse-temurin:21.0.8_9-jdk sh -c 'java -cp out/app-release.jar com.example.shop.Main 2> out/trace.txt; cat out/trace.txt'والمخرج سوف يكون كما يلي، الأول من النسخة العادية:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "<parameter1>" is null
at com.example.shop.CouponRules.discountPercent(CouponRules.java:5)
at com.example.shop.Cart.totalAfterCoupon(Cart.java:11)
at com.example.shop.CheckoutScreen.onPayClicked(CheckoutScreen.java:8)
at com.example.shop.Main.main(Main.java:6)والثاني من نسخة الإصدار، وهو ما سوف يصلك من جهاز المستخدم:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "<parameter1>" is null
at a.b.a(SourceFile:1)
at com.example.shop.Main.main(SourceFile:2)في المخرج أعلاه لاحظ التالي:
- أربعة إطارات في النسخة العادية أصبحت إطارين، والسبب أن R8 دمج
discountPercentوtotalAfterCouponداخلonPayClicked، ثم سمى الكلاسCheckoutScreenباسمa.bوالدالة باسمa، فالسطر الذي يحتوي على الخطأ الحقيقي غير موجود في ال Stack Trace أصلاً. - اسم الملف أصبح
SourceFileبسبب-renamesourcefileattribute، وأرقام الأسطر 1 و2 ليست أسطر الكود، وإنما أرقام يحولها ملف ال Mapping إلى الأسطر الأصلية. - الكلاس
Mainبقي باسمه لأننا طلبنا ذلك بقاعدة-keep، ولو لم نفعل لما وجد البرنامج نقطة البداية.
وملف mapping.txt الذي خرج مع البناء يبين كيف حدث ذلك، وهذا جزء منه:
# compiler: R8
# compiler_version: 9.5.28
# pg_map_id: fad30d88441bb062d8014ffb99a2d2e04ecc67278cc87d864d208aea8e82a32e
com.example.shop.Cart -> R8$$REMOVED$$CLASS$$0:
com.example.shop.CheckoutScreen -> a.b:
1:1:void <init>():3:3 -> <init>
1:1:int com.example.shop.CouponRules.discountPercent(java.lang.String):5:5 -> a
1:1:long com.example.shop.Cart.totalAfterCoupon(java.lang.String):11 -> a
1:1:void onPayClicked(java.lang.String):8 -> a
2:2:long com.example.shop.Cart.totalAfterCoupon(java.lang.String):12:12 -> a
2:2:void onPayClicked(java.lang.String):8 -> a
3:4:void onPayClicked(java.lang.String):8:9 -> a
com.example.shop.CouponRules -> R8$$REMOVED$$CLASS$$1:
com.example.shop.PriceFormatter -> a.c:
1:1:java.lang.String format(long):5:5 -> aلاحظ أن السطر 1 في الدالة a يقابل ثلاثة أسطر أصلية متداخلة: السطر 5 في discountPercent، داخل السطر 11 في totalAfterCoupon، داخل السطر 8 في onPayClicked، وأن الكلاسين Cart وCouponRules حذفا بالكامل لأن كل كودهما دمج في غيرهما. الآن نعطي أداة Retrace الملف وال Stack Trace:
docker run --rm -v "$(pwd)":/work -w /work eclipse-temurin:21.0.8_9-jdk java -cp r8.jar com.android.tools.r8.retrace.Retrace out/mapping.txt out/trace.txtوالمخرج:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "<parameter1>" is null
at com.example.shop.CouponRules.discountPercent(CouponRules.java:5)
at com.example.shop.Cart.totalAfterCoupon(Cart.java:11)
at com.example.shop.CheckoutScreen.onPayClicked(CheckoutScreen.java:8)
at com.example.shop.Main.main(Main.java:6)وهو مطابق تماماً لل Stack Trace من النسخة العادية، بما في ذلك الإطاران اللذان دمجهما R8. وهذا ما تفعله كل أدوات تقارير الأعطال عندما ترفع لها ملف ال Mapping، والأداة retrace في Android SDK هي نفسها هذه الأداة. ولاحظ أن الرسالة نفسها تقول <parameter1> وليس couponCode، والسبب أن أسماء المعاملات Parameters لا تحفظ في ال Bytecode إلا بخيار خاص، وملف ال Mapping لا يعيد النص داخل الرسائل، فاكتب رسائل الأخطاء بحيث تفهم دون أسماء المتغيرات.
ماذا يحدث إذا استخدمت ملف Mapping من بناء آخر؟
هذا هو الخطأ الأشهر في هذا الموضوع، فملف ال Mapping يتغير مع كل بناء، وتوثيق Android ينبه إلى أن البناء يكتب فوق الملف في كل مرة، فإذا لم ترفعه أو تحفظه مع كل إصدار ثم احتجته لاحقاً فسوف تجد ملف البناء الأخير فقط. لنجرب ذلك: نضيف إلى الكود كلاساً جديداً ReceiptPrinter يطبع المجموع بدلاً من System.out، أي تعديل صغير كالذي يحدث بين إصدارين، ثم نبني الإصدار الثاني ونستخدم ملف ال Mapping الجديد لقراءة ال Stack Trace القديم الذي جاء من الإصدار الأول:
docker run --rm -v "$(pwd)":/work -w /work eclipse-temurin:21.0.8_9-jdk java -cp r8.jar com.android.tools.r8.retrace.Retrace v2/out/mapping.txt out/trace.txtوالمخرج:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "<parameter1>" is null
at com.example.shop.ReceiptPrinter.print(ReceiptPrinter.java:5)
at com.example.shop.CouponRules.discountPercent(CouponRules.java:5)
at com.example.shop.Cart.totalAfterCoupon(Cart.java:11)
at com.example.shop.CheckoutScreen.onPayClicked(CheckoutScreen.java:9)
at com.example.shop.Main.main(Main.java:6)لاحظ أن الأداة لم تعرض أي خطأ أو تحذير، وإنما أعطت Stack Trace يبدو سليماً ولكنه غير صحيح، ففيه إطار ReceiptPrinter.print لم يكن موجوداً في الإصدار الذي تعطل أصلاً، لأن الاسم a.b في البناء الثاني أصبح يعني ReceiptPrinter، والسطر في onPayClicked أصبح 9 بدلاً من 8. وهذا أسوأ من Stack Trace مشفر، لأن المشفر تعرف أنك لا تفهمه، وأما هذا فقد يرسلك للبحث في كلاس لا علاقة له بالعطل. ولذلك ارفع ملف ال Mapping أو احفظه مع كل بناء يذهب إلى المستخدمين، واربطه برقم الإصدار، والأدوات الحديثة تفعل ذلك تلقائياً بإضافة Gradle، وتستخدم المعرف pg_map_id الذي رأيته في رأس الملف لتربط كل عطل بملفه الصحيح.
تجربة: تحويل عناوين مكتبة C إلى أسماء الدوال Symbols والأسطر
الفكرة نفسها في الكود الأصلي، ولكن بعناوين بدلاً من أسماء مختصرة. المثال التالي مكتبة C صغيرة prices.c تحسب المجموع، وفيها الخطأ نفسه: رمز خصم فارغ NULL:
#include <stddef.h>
struct cart { long subtotal; const char *coupon; };
static int coupon_percent(const char *coupon) {
return coupon[0] == 'V' ? 20 : 0;
}
long cart_total(const struct cart *c) {
int percent = coupon_percent(c->coupon);
return c->subtotal - c->subtotal * percent / 100;
}والبرنامج main.c يستدعي المكتبة، ويسجل معالجاً للإشارة SIGSEGV يطبع العناوين بالشكل الذي تطبعه بها تقارير Android، أي عنوان pc نسبة إلى بداية كل مكتبة، وهذا تبسيط لما تفعله مكتبات مثل Crashpad وBreakpad:
#define _GNU_SOURCE
#include <dlfcn.h>
#include <execinfo.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
struct cart { long subtotal; const char *coupon; };
long cart_total(const struct cart *c);
static void on_crash(int sig, siginfo_t *info, void *ctx) {
void *frames[16];
int n = backtrace(frames, 16);
fprintf(stderr, "signal %d (%s), fault addr %p\nbacktrace:\n", sig, sig == SIGSEGV ? "SIGSEGV" : "?", info->si_addr);
for (int i = 0; i < n; i++) {
Dl_info d;
if (dladdr(frames[i], &d) && d.dli_fname) {
fprintf(stderr, " #%02d pc %016lx %s\n", i, (unsigned long)((char *)frames[i] - (char *)d.dli_fbase), d.dli_fname);
}
}
_exit(128 + sig);
}
int main(void) {
struct sigaction sa = {0};
sa.sa_sigaction = on_crash;
sa.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sa, NULL);
struct cart c = { 15000, NULL };
printf("total=%ld\n", cart_total(&c));
return 0;
}نبني المكتبة بمعلومات التصحيح -g، ثم نحتفظ بنسخة منها غير مجردة Unstripped، ونجرد النسخة التي سوف نوزعها بالأمر strip كما يفعل ال NDK مع المكتبات داخل ملف APK أو AAB، ثم نشغل البرنامج ليتعطل، وهذه هي الأوامر داخل Container من ال Image eclipse-temurin:21.0.8_9-jdk المبنية على Ubuntu 24.04، بعد تثبيت gcc وbinutils:
gcc -g -O2 -fPIC -shared -o libprices.so prices.c
gcc -g -O2 -o shop main.c -L. -lprices -Wl,-rpath,'$ORIGIN'
cp libprices.so libprices-unstripped.so
strip --strip-unneeded libprices.so
ls -l libprices.so libprices-unstripped.so
./shop 2> trace.txt; cat trace.txtوالمخرج:
-rwxr-xr-x 1 root root 14016 Oct 23 08:33 libprices.so
-rwxr-xr-x 1 root root 16696 Oct 23 08:33 libprices-unstripped.so
signal 11 (SIGSEGV), fault addr (nil)
backtrace:
#00 pc 0000000000001320 ./shop
#01 pc 0000000000045330 /lib/x86_64-linux-gnu/libc.so.6
#02 pc 000000000000110d /work/libprices.so
#03 pc 00000000000011ac ./shop
#04 pc 000000000002a1ca /lib/x86_64-linux-gnu/libc.so.6
#05 pc 000000000002a28b /lib/x86_64-linux-gnu/libc.so.6
#06 pc 0000000000001215 ./shopالإطاران الأولان هما معالج الإشارة نفسه ودالة النظام التي استدعته، والعطل الحقيقي في الإطار #02: العنوان 0x110d داخل libprices.so، وعنوان الخطأ nil أي أن الكود قرأ من العنوان صفر. الآن نحاول تحويل العنوان بالأداة addr2line، مرة بالمكتبة المجردة التي وزعناها، ومرة بالنسخة غير المجردة التي احتفظنا بها:
addr2line -f -i -e libprices.so 0x110d
addr2line -f -i -C -e libprices-unstripped.so 0x110d
readelf -n libprices.so | grep "Build ID"
readelf -n libprices-unstripped.so | grep "Build ID"والمخرج:
cart_total
??:?
coupon_percent
/work/prices.c:6
cart_total
/work/prices.c:10
Build ID: 351f31074a2de9e0313e12fc87cf2d6cb7def6e4
Build ID: 351f31074a2de9e0313e12fc87cf2d6cb7def6e4في المخرج أعلاه لاحظ التالي:
- المكتبة المجردة عرفت اسم
cart_totalفقط، لأنها دالة مصدرة Exported يحتاجها الربط الديناميكي، ولكنها لم تعرف الملف ولا السطر (??:?)، ولم تعرف أن العطل وقع فعلياً في الدالةcoupon_percentالتي دمجها المترجم داخلcart_total. - النسخة غير المجردة أعطت الصورة كاملة: السطر 6 في
coupon_percentحيث نقرأcoupon[0]، وقد استدعيت من السطر 10 فيcart_total. - النسختان تحملان معرف البناء نفسه Build ID، وبهذا المعرف تعرف أداة تقارير الأعطال أي ملف رموز يخص أي مكتبة، كما يفعل ال UUID مع ملفات dSYM في iOS. والأداة الرسمية في Android لهذا العمل هي
ndk-stack، وتعطيها مجلد المكتبات غير المجردة مع ال Stack Trace منadb logcatأو من ملف ال Tombstone.
وفي Google Play تستطيع رفع رموز NDK مع التطبيق، فبحسب صفحة إزالة تشفير الأعطال في Play Console يكفي أن تضيف ndk.debugSymbolLevel = 'FULL' في إعداد Gradle ليضم ال App Bundle ملف الرموز، أو SYMBOL_TABLE إذا كان الملف كبيراً، والحد 800 MB، وملف ال Mapping يرفع تلقائياً مع ال App Bundle. ولاحظ أن الصفحة نفسها تقول إن Play Console لا يعيد قراءة الأعطال التي وصلت قبل رفع الملف، وإنما يقرأ ما يصل بعده فقط، وهذا سبب آخر لترفع ملفات الرموز مع البناء نفسه وليس عندما تحتاجها.
كيف تقيس استقرار التطبيق Stability؟
عدد الأعطال وحده لا يعني شيئاً، فمئة عطل في يوم لتطبيق عنده مليون مستخدم تختلف عن مئة عطل لتطبيق عنده ألف، ولذلك تقيس الأدوات الاستقرار بنسبتين، وتعرفهما صفحة Release Health في Sentry كما يلي:
- نسبة الجلسات دون أعطال Crash-free Sessions: نسبة الجلسات التي لم تنته بعطل، والجلسة Session في تطبيقات الجوال تبدأ عند فتح التطبيق أو عودته من الخلفية، وتنتهي عند إغلاقه أو ذهابه إلى الخلفية، وإذا عاد خلال 30 ثانية فهي الجلسة نفسها.
- نسبة المستخدمين دون أعطال Crash-free Users: نسبة المستخدمين الذين لم يواجهوا أي عطل في المدة المحددة.

لنأخذ المثال الذي في الصورة: عشرة مستخدمين فتحوا التطبيق 40 مرة في يوم، وتعطلت 3 جلسات، اثنتان منها لمستخدم واحد يستخدم التطبيق كثيراً، والثالثة لمستخدم آخر. فنسبة الجلسات دون أعطال (40 - 3) / 40 = 92.5%، ونسبة المستخدمين دون أعطال (10 - 2) / 10 = 80%. ونسبة المستخدمين هي الأقرب لما يشعر به جمهورك، فخمس المستخدمين رأوا التطبيق يغلق اليوم، وأما نسبة الجلسات فهي أدق في متابعة إصلاح عطل معين، لأنها تتحرك مع كل جلسة. وعلى أي حال انظر دائماً إلى الرقمين لكل إصدار على حدة وليس للتطبيق كله، لأن الإصدار القديم الذي يستخدمه أغلب الناس يخفي مشكلة الإصدار الجديد الذي لم يصل إلا إلى 5% منهم، وهذا ما تسميه الأدوات صحة الإصدار Release Health، ومعه نسبة الاعتماد Adoption، أي كم من الجلسات أو المستخدمين في آخر 24 ساعة على هذا الإصدار.
حدود Google Play في Android vitals
Google Play نفسه يقيس استقرار تطبيقك دون أي SDK، من الأجهزة التي وافق أصحابها على مشاركة بيانات الاستخدام والتشخيص، ويعرضها في Play Console تحت Android vitals. والفرق المهم هنا أن هذه الأرقام تؤثر في ظهور تطبيقك، فبحسب صفحة Android vitals إذا تجاوز التطبيق حد السلوك السيئ Bad Behavior Threshold فقد يقلل Play ظهوره في المتجر، وقد يعرض للمستخدمين تحذيراً في صفحة التطبيق، والتقييم على متوسط آخر 28 يوماً، والحدود كما ظهرت في 23 أكتوبر 2026:
| المقياس | الحد لكل الأجهزة Overall | الحد لطراز جوال واحد |
|---|---|---|
| نسبة الأعطال التي يلاحظها المستخدم User-perceived Crash Rate | 1.09% | 8% |
| نسبة ال ANR التي يلاحظها المستخدم User-perceived ANR Rate | 0.47% | 8% |
| الاستهلاك الزائد للبطارية Excessive Battery Usage | 1% | لا يوجد |
| Partial Wake Locks الزائدة | 5% | لا يوجد |
والنسبة هنا بحسب تعريف Play Console هي نسبة المستخدمين اليوميين الذين واجهوا عطلاً واحداً على الأقل يلاحظه المستخدم، أي أنها أقرب إلى عكس Crash-free Users، فحد 1.09% يعني تقريباً أن يكون 98.91% من مستخدميك اليوميين دون أعطال ملحوظة. ولاحظ أن ال ANR المحسوبة هنا هي من نوع «انتهاء مهلة الإدخال» Input Dispatching Timed Out فقط، وأن الحد لطراز جوال واحد مهم، فقد يكون تطبيقك مستقراً في المجمل ولكنه يتعطل في 9% من جلسات جوال معين شائع عند جمهورك.
وقد يتساءل البعض: إذا كان Play Console يعطيني هذه الأرقام مجاناً فلماذا أحتاج أداة أخرى؟ والإجابة أن Android vitals يعطيك المقياس والمجموعات العامة، ولكنه لا يعرف ماذا فعل المستخدم قبل العطل، ولا يعرف الأخطاء غير القاتلة، ولا يرسل تنبيهاً عند ظهور عطل جديد، ولا يغطي إلا التطبيقات المثبتة من Google Play على أجهزة معتمدة. فهو المرجع الذي يحاسبك به المتجر، والأداة هي التي تساعدك على الإصلاح، ولذلك راقب الاثنين.
Xcode Organizer في iOS
وفي iOS تعرض Apple الأعطال في نافذة Organizer داخل Xcode، من المستخدمين الذين وافقوا على مشاركة بيانات التشخيص مع المطورين، ومن مستخدمي TestFlight دائماً بغض النظر عن إعداداتهم، كما تقول صفحة الحصول على تقارير الأعطال. وإذا رفعت ملفات dSYM مع البناء إلى App Store Connect فإن التقارير تصلك مقروءة. والنافذة نفسها تعرض مقاييس الأداء من أجهزة المستخدمين، مثل زمن التشغيل والذاكرة وسرعة الاستجابة والبطارية، بحسب صفحة تحليل أداء التطبيق المنشور. ولكن الصفحة الأولى تنبه إلى أن بعض الأنواع لا تظهر في Organizer، ومنها أحداث ال Watchdog عند بطء التشغيل وأحداث Jetsam بسبب الذاكرة، وهذا سبب آخر لأداة داخل التطبيق.
ما الذي يجعل تقرير العطل Crash Report مفيداً؟
ال Stack Trace المقروء يخبرك أين تعطل الكود، ولكنه لا يخبرك دائماً لماذا، فقيمة null في رمز الخصم قد تكون لأن المستخدم ضغط زر الدفع قبل أن تحمل الشاشة، أو لأن السيرفر أرجع استجابة غير متوقعة، ولذلك تجمع الأدوات مع العطل سياقاً Context، وفيما يلي أهمه:
- ال Breadcrumbs: قائمة بآخر الأحداث قبل العطل، مثل فتح شاشة، والضغط على زر، وطلب HTTP مع رمز الاستجابة، وتغير حالة الشبكة. والمكتبات تسجل كثيراً منها تلقائياً، وتستطيع إضافة ما يخص تطبيقك، مثل «تم تطبيق رمز الخصم».
- الإصدار Release: اسم الإصدار ورقم البناء، أي
versionNameوversionCodeفي Android، وCFBundleShortVersionStringوCFBundleVersionفي iOS. ولا تقم بإعادة استخدام رقم البناء نفسه لبناءين مختلفين، والسبب أن الأداة تربط ملف ال Mapping أو ال dSYM وصحة الإصدار بهذا الرقم، فإذا تكرر اختلطت ملفات الرموز كما رأينا في التجربة. - البيئة Environment: production أو staging أو نسخة TestFlight، حتى لا تختلط أعطال الفريق الداخلي بأعطال المستخدمين.
- الجهاز والنظام: الطراز ونسخة النظام والذاكرة المتاحة واتجاه الشاشة واللغة، وتجمعها المكتبة تلقائياً، وهي التي تكشف أن العطل في جوال واحد أو في نسخة نظام واحدة.
- المفاتيح المخصصة Custom Keys: قيم من حالة التطبيق تساعد في الفهم، مثل نوع الاشتراك أو عدد العناصر في السلة أو مزود الدفع المختار.
- معرف المستخدم User ID: لمعرفة عدد المستخدمين المتأثرين وللرد على بلاغ عميل محدد، ويكفي فيه معرف داخلي عشوائي، وليس البريد ولا رقم الجوال كما في فقرة الخصوصية.
الإطلاق التدريجي Staged Rollout: متى توقف الإصدار؟
في تطبيقات الويب إذا نشرت إصداراً فيه عطل فإنك ترجع للإصدار السابق Rollback في دقائق، وأما في الجوال فالإصدار الذي وصل إلى جهاز المستخدم يبقى عليه حتى يحدثه بنفسه، ولا توجد طريقة لسحبه، ولذلك فإن أهم أداة لحماية المستخدمين هي ألا يصل الإصدار الجديد إلى الجميع مرة واحدة، وأن تراقب الأعطال في النسبة الأولى قبل أن توسعها. وهي الفكرة نفسها التي شرحناها للخوادم في مقال Feature Flags والإطلاق التدريجي، ولكن المتجر هنا هو من يتحكم في التوزيع:
- Google Play: بحسب صفحة الإطلاق التدريجي تختار نسبة المستخدمين الذين يصلهم الإصدار، ويختارهم Play عشوائياً، والنسبة لا تزيد تلقائياً وإنما ترفعها أنت. وتستطيع إيقاف الإطلاق Halt، فلا يصل الإصدار إلى مستخدمين جدد، ولكن من وصلهم يبقون عليه، ثم تستأنفه Resume إذا تبين أن المشكلة ليست في التطبيق.
- App Store: بحسب صفحة الإصدار على مراحل يصل التحديث خلال 7 أيام بالنسب 1% ثم 2% ثم 5% ثم 10% ثم 20% ثم 50% ثم 100%، لمن فعلوا التحديث التلقائي، وتستطيع إيقافه مؤقتاً 30 يوماً بالمجموع. ولاحظ أن الصفحة نفسها تقول إن أي مستخدم يستطيع تنزيل التحديث يدوياً من المتجر في أي وقت، فالنسبة ليست حداً صارماً.

متى توقف الإطلاق إذاً؟ لا يوجد رقم واحد يصلح لكل تطبيق، ولكن ضع القاعدة قبل النشر وليس بعد ظهور المشكلة، والسبب أن الفريق الذي ينتظر ميزة جديدة سوف يجد دائماً سبباً للاستمرار. وفيما يلي قواعد عملية تستطيع أن تبدأ منها:
- قارن الإصدار الجديد بالإصدار السابق وليس برقم مطلق: إذا كانت نسبة المستخدمين دون أعطال في الإصدار السابق 99.6% وهبطت في الجديد إلى 98.7%، فهذا تضاعف في عدد المتأثرين تقريباً، حتى لو بدا الرقم مرتفعاً.
- عطل جديد في مسار المال أو الدخول: أي عطل جديد في تسجيل الدخول أو الدفع أو التسجيل يوقف الإطلاق مهما كان عدده، لأن المستخدم هنا يخسر الخدمة كلها.
- عطل عند بدء التشغيل: يوقف الإطلاق فوراً، لأن المستخدم لا يستطيع حتى فتح التطبيق ليصله الإصلاح عبر إعدادات عن بعد Remote Config.
- الاقتراب من حدود Android vitals: إذا اقتربت نسبة الإصدار الجديد من 1.09% للأعطال أو 0.47% لل ANR، فالتوسيع سوف يرفع متوسط التطبيق كله.
- انتظر عدداً كافياً من الجلسات: نسبة 1% من مستخدمين قليلين في ساعة واحدة لا تكفي لقرار، فحدد عدداً أدنى من الجلسات أو مدة دنيا لكل مرحلة قبل أن ترفع النسبة.
ولاحظ أن الإيقاف يحمي من لم يصلهم الإصدار فقط، وأما من وصلهم فيحتاجون إلى إصدار تصحيحي Hotfix برقم بناء جديد، أو إلى إطفاء الميزة المعطلة من ال Feature Flag إذا كنت قد وضعتها خلفه، وهذا هو السبب في أن الميزات الكبيرة في تطبيقات الجوال توضع خلف Feature Flag من البداية.
الخصوصية Privacy: ما الذي لا يجب أن يخرج في تقرير العطل؟
تقرير العطل يخرج من جهاز المستخدم إلى سيرفر غالباً خارج المملكة، وهو يحمل أكثر مما تتوقع: معرف الجهاز أو التثبيت، وعنوان IP في طلب الإرسال، وال Breadcrumbs التي قد تحتوي على روابط فيها توكن Token أو رقم طلب، والمفاتيح المخصصة، ورسائل الاستثناءات التي قد تحتوي على مدخلات المستخدم، وفي بعض الأدوات لقطات الشاشة Screenshots وتسجيل الجلسة Session Replay. ولذلك فإن القاعدة أن ترسل ما يساعدك على الإصلاح فقط، والجدول التالي يبين الفرق:
| أرسله | لا ترسله |
|---|---|
| معرف داخلي عشوائي للمستخدم | البريد ورقم الجوال ورقم الهوية والاسم |
| اسم الشاشة ونوع الإجراء وكود حالة HTTP | رابط الطلب كاملاً مع ال Query String والتوكن |
| نوع الاشتراك وعدد العناصر في السلة | رقم البطاقة والعنوان والموقع الجغرافي الدقيق |
| رسالة خطأ تصف الحالة | رسالة تحتوي على ما كتبه المستخدم في حقل |
| Session Replay مع إخفاء النصوص والصور، إن احتجته | لقطات شاشة دون إخفاء في شاشات الدفع والملف الشخصي |
وكل الأدوات توفر دالة تمر عليها كل الأحداث قبل الإرسال، مثل beforeSend، لتحذف الحقول الحساسة أو تخفيها، وأغلبها يسمح بأن تؤجل جمع التقارير حتى يوافق المستخدم، وهذا ما سوف نراه لكل أداة في المقال التالي. وتذكر أن ما ترسله الأداة يجب أن تصرح به في المتجر، ففي Google Play تحت قسم أمان البيانات Data Safety يوجد نوع «سجلات الأعطال» Crash Logs ضمن فئة معلومات التطبيق وأدائه، وفي App Store تحت تفاصيل خصوصية التطبيق يوجد نوع Crash Data ضمن Diagnostics، وApple تنص على أن تصرح أيضاً بما تجمعه ال SDK من جهات خارجية في تطبيقك.
وفي المملكة يضع نظام حماية البيانات الشخصية ولائحة نقل البيانات الشخصية خارج المملكة شروطاً على نقل البيانات الشخصية إلى الخارج، وتقرير العطل الذي يحمل معرف المستخدم أو بريده أو موقعه بيانات شخصية تنتقل إلى مزود في الولايات المتحدة أو أوروبا، وقد ناقشنا سؤال مكان البيانات في مقال ال Backend as a Service وبدائل Firebase. والحل الأبسط هنا أن تقلل البيانات الشخصية في التقرير إلى ما لا يمكن ربطه بشخص، فإذا كانت جهتك حكومية أو صحية أو مالية، أو كانت سياستها أن تبقى البيانات داخل المملكة، فالأنسب أداة تستضيفها بنفسك على سيرفر داخل المملكة، وهذا ما سوف نقارنه في المقال التالي.
أخطاء شائعة
- عدم رفع ملف ال Mapping أو ال dSYM مع كل بناء: فتصل الأعطال مشفرة، وكثير من الأدوات لا تعيد قراءة ما وصل قبل الرفع.
- استخدام ملف رموز من بناء آخر: فيعطيك Stack Trace يبدو سليماً وهو غير صحيح، كما رأينا في التجربة.
- تهيئة مكتبة الأعطال متأخرة: بعد مكتبات أخرى أو بعد طلب شبكة، فلا ترى أعطال بدء التشغيل وهي أخطرها.
- نسيان الجزء الأصلي Native: التطبيق فيه مكتبة C/C++ عبر مكتبة خارجية، والأداة لا تلتقط إلا أعطال Java، فترى في Android vitals أعطالاً لا تجدها في لوحتك.
- قراءة نسبة التطبيق كله: بدلاً من نسبة كل إصدار، فلا ترى مشكلة الإصدار الجديد إلا بعد أن يصل إلى الجميع.
- تسجيل كل استثناء متوقع Non-fatal: حتى تمتلئ اللوحة بانقطاعات الشبكة وتختفي الأخطاء الحقيقية.
- إرسال البريد أو رقم الجوال معرفاً للمستخدم: والمعرف الداخلي العشوائي يكفي لكل أغراض التحقيق.
- الإطلاق إلى 100% مباشرة: وفي الجوال لا يوجد Rollback، فالإطلاق التدريجي هو خط الدفاع الوحيد.
الخلاصة
- مكتبة تقارير الأعطال تكتب التقرير على القرص لحظة العطل وترسله في التشغيل التالي، ولذلك هيئها أول شيء عند تشغيل التطبيق.
- الأعطال أنواع: Crash وNative Crash وANR وApp Hang وOut of Memory وWatchdog وNon-fatal، وبعضها ينفذه النظام دون Stack Trace فتستنتجه الأدوات، وفي Android 11 وما بعده تعطيك
ApplicationExitInfoالسبب الفعلي. - ال Stack Trace من الجوال مشفر أو بعناوين، وتعيده إلى أصله بملف الرموز من البناء نفسه:
mapping.txtأو dSYM أو مكتبات.soغير مجردة أو ملفات Flutter، وفي التجربة أعاد Retrace إطارين إلى أربعة إطارات أصلية، وأعطى ملف من بناء آخر نتيجة خاطئة دون أي تحذير. - راقب Crash-free Users وCrash-free Sessions لكل إصدار، وحدود Android vitals هي 1.09% للأعطال و0.47% لل ANR لكل الأجهزة، و8% لطراز جوال واحد.
- أطلق كل إصدار تدريجياً، وحدد قاعدة الإيقاف قبل النشر، وتذكر أن الإيقاف لا يعيد من وصلهم الإصدار.
- أرسل في التقرير ما يساعد على الإصلاح فقط، دون بريد أو رقم جوال أو توكن، وصرح به في المتجر، وانتبه لنقل البيانات خارج المملكة.
وفي المقال التالي نقارن الأدوات التي تنفذ كل ما سبق، من Firebase Crashlytics المجاني إلى البدائل التي تستضيفها بنفسك، ثم نبني في المختبر تطبيق Android يرسل أعطاله إلى سيرفرك:
دليلأدوات تقارير أعطال الجوال: Crashlytics والبدائل المفتوحةمقارنة Firebase Crashlytics بالخدمات التجارية والأدوات التي تستضيفها بنفسك، بالأسعار ومكان البيانات ودعم Android وiOS وFlutter.
دليلتقارير الأعطال Crash Reports لتطبيق Android مع GlitchTipتطبيق Android يتعطل عمداً على المحاكي ويرسل أعطاله إلى GlitchTip على سيرفرك، وما الذي يعمل فعلاً عند رفع ملف ال Mapping وما الذي لا يعمل.تدريب عملي
- في مشروع Android لديك، فعل
isMinifyEnabled = trueفي نسخة ال Release، ثم ابحث عنmapping.txtبعد البناء، وجرب أداةretraceعلى Stack Trace من التطبيق نفسه. - افتح Android vitals في Play Console أو Organizer في Xcode لتطبيقك، وقارن نسبة الأعطال لآخر إصدارين، وحدد الرقم الذي كنت سوف توقف عنده الإطلاق.
سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتجربة إعادة Stack Trace مشفر بأداة R8 Retrace 9.5.28، وتحويل عناوين مكتبة C إلى أسطر بالأداة
addr2line، في Docker.