الاستضافة الذاتية والتطبيقات الأدلة التقنية

تقارير الأعطال Crash Reports لتطبيق Android مع GlitchTip

تطبيق Android يتعطل عمداً على المحاكي ويرسل أعطاله إلى GlitchTip على سيرفرك، وما الذي يعمل فعلاً عند رفع ملف ال Mapping وما الذي لا يعمل.

تقارير الأعطال Crash Reports لتطبيق Android مع GlitchTip

في مقال تقارير الأعطال Crash Reporting في تطبيقات الجوال ذكرنا أن العطل يكتب على قرص الجوال لحظة حدوثه ويرسل في التشغيل التالي، وأن ال Stack Trace يصلك بأسماء مشفرة حتى ترفع ملف ال Mapping من البناء نفسه، وفي مقال أدوات تقارير أعطال الجوال قارنا Crashlytics بالبدائل التي تستضيفها بنفسك. والسؤال الآن عملي: إذا أخذت تطبيق Android حقيقياً بنسخة Release مشفرة بـ R8، وربطته بـ GlitchTip على سيرفرك عبر مكتبة Sentry، فهل يصلك العطل مقروءاً فعلاً؟ وما الذي يعمل كما في التوثيق وما الذي لا يعمل؟

في هذا المقال سوف نبني تطبيق Kotlin صغيراً لمتجر، يتعطل عمداً عند حساب المجموع لأن السعر وصل من ال API بالنص "120 ر.س"، ونشغله على محاكي Android، ونتابع العطل حتى يصل إلى GlitchTip. وسوف نتناول:

  • بناء نسخة Release مشفرة بـ R8 مع مكتبة Sentry وإضافة Gradle الخاصة بها.
  • تشغيل التطبيق على المحاكي Emulator وربطه بـ GlitchTip على الجهاز نفسه.
  • ال Stack Trace المشفر كما يصل، ثم رفع ملف ال Mapping: ما الذي قبله GlitchTip 6.2.6 وما الذي رفضه دون أن يخبرك، والحل المؤقت.
  • الترجمة الكاملة محلياً بأداة Retrace، وال Breadcrumbs، والخطأ غير القاتل Non-fatal.
  • عدم الاستجابة ANR في Android 15، ولماذا لا يصلك إلا في التشغيل التالي.
  • الإصدارات Releases والتنبيه بالبريد، ونسبة المستخدمين دون أعطال Crash-free التي لا يحسبها GlitchTip، والموارد التي استهلكتها التجربة.

والمواضيع في هذه السلسلة:

  1. مفاهيم تقارير الأعطال في تطبيقات الجوال.
  2. أدوات تقارير أعطال الجوال: Firebase Crashlytics والخدمات التجارية والبدائل مفتوحة المصدر.
  3. مختبر عملي: أعطال تطبيق Android تصل إلى سيرفرك مع قراءة ال Stack Trace (الموضوع الحالي).

والصورة التالية تبين المسار الكامل للتجربة، ومنها الطريقان اللذان جربناهما لرفع ملف ال Mapping:

مسار التجربة: جهاز البناء يشغل Gradle وR8 وينتج app-release.apk بحجم 3.8 ميجابايت وملف mapping.txt بحجم 6.4 ميجابايت، ثم يثبت التطبيق على محاكي Android 15 الذي يرسل الحدث عبر adb reverse إلى GlitchTip 6.2.6، ورفع ملف ال Mapping بإضافة Gradle عبر sentry-cli 3.8.0 يطبع UPLOADED ولكن GlitchTip لا يحفظه، أما الرفع بملف ZIP عبر curl فيحفظ ويحتاج إضافة الحقل kind لتجاوز الخلل 504، وGlitchTip يعيد اسم الكلاس والدالة ويبقي رقم السطر، والترجمة الكاملة تبقى بأداة retrace محلياً
الحدث يصل من المحاكي، وملف ال Mapping يصل بطريق واحد من طريقين

أين يعمل التطبيق في هذه التجربة؟

قبل أن نبدأ من المهم أن تعرف بالضبط ما الذي يعمل على جهاز Android وما الذي لا يعمل، لأن تقارير الأعطال تعتمد على سلوك النظام نفسه، مثل إنهاء التطبيق بسبب ANR وسجل الخروج ApplicationExitInfo، وهذه لا تظهر إذا شغلت الكود على JVM عادية كما فعلنا في تجربة R8 في مقال المفاهيم:

الجزءأين يعمل
التطبيق ومكتبة Sentry وال Crash وال ANRعلى المحاكي الرسمي Android Emulator بنظام Android 15 (API 35) x86_64 بال System Image الخاص بـ Google Play، مع تسريع العتاد Hardware Acceleration عبر Windows Hypervisor Platform
البناء بـ Gradle وR8 وأداة Retraceعلى جهاز التطوير نفسه، خارج Docker
GlitchTip وPostgreSQL وValkeyفي Docker على جهاز التطوير
جوال حقيقي، وiOS، وGoogle Play Consoleلم تدخل في التجربة

والمحاكي يشغل نظام Android كاملاً ونواته Kernel، فال Crash وال ANR وسجل الخروج حقيقية وليست محاكاة من المكتبة، ولكن الجوالات الحقيقية تختلف عنه في أمرين: الشبكة غير مستقرة، وبعض الشركات المصنعة تنهي التطبيقات في الخلفية بطرق خاصة بها، وهذا يؤثر على وصول التقارير وليس على محتواها.

المتطلبات

  • Android SDK والمحاكي: من Android Studio، مع ال System Image لنظام Android 15 x86_64، وجهاز افتراضي AVD تنشئه من Device Manager. والمحاكي يحتاج تسريع العتاد، وهو في Windows عبر Windows Hypervisor Platform، وفي Linux عبر KVM، وتتحقق منه بالأمر emulator-check accel الذي أعطى في التجربة WHPX(10.0.26100) is installed and usable.
  • الذاكرة والقرص: المحاكي وحده استهلك قرابة 2.3GB من الذاكرة RAM، والجهاز الافتراضي أخذ 4.3GB على القرص بعد التجربة، وال System Image بحجم 2.2GB، والتفاصيل في قسم الموارد.
  • JDK 17 أو أحدث: استخدمنا JDK المرفق مع Android Studio (OpenJDK 21.0.5).
  • Docker: لتشغيل GlitchTip كما في دليل GlitchTip.

وكل أسماء ال Containers وال Volumes تبدأ بالبادئة mc2-، والمنفذ الوحيد هو 43500 على 127.0.0.1.

تشغيل GlitchTip

شرحنا GlitchTip وإعداداته بالتفصيل في دليله، لذلك نكتفي هنا بملف compose.yaml مع إضافة واحدة هي فحص الجاهزية Healthcheck لقاعدة البيانات، والسبب أن GlitchTip يبدأ الترحيل Migration فور تشغيله، فإذا لم تكن PostgreSQL جاهزة يتوقف بالخطأ Connection refused، وهذا ما حدث معنا في التشغيل الأول قبل إضافة الفحص:

name: mc2-crash
x-gt-env: &gt-env
  DATABASE_URL: postgres://postgres:lab-only-pass@mc2-gt-db:5432/postgres
  VALKEY_URL: redis://mc2-gt-valkey:6379
  SECRET_KEY: lab-only-5d1e8a7c3b9f40e2a6c4d8b1f3e5a7c9
  EMAIL_URL: consolemail://
  GLITCHTIP_DOMAIN: http://127.0.0.1:43500
  DEFAULT_FROM_EMAIL: [email protected]
  SERVER_ROLE: all_in_one
services:
  db:
    image: postgres:18.6
    container_name: mc2-gt-db
    environment:
      POSTGRES_PASSWORD: lab-only-pass
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 20
    volumes:
      - mc2-gt-pg:/var/lib/postgresql
  valkey:
    image: valkey/valkey:9.0.6-alpine
    container_name: mc2-gt-valkey
  web:
    image: glitchtip/glitchtip:6.2.6
    container_name: mc2-gt-web
    depends_on:
      db:
        condition: service_healthy
      valkey:
        condition: service_started
    environment: *gt-env
    ports:
      - "127.0.0.1:43500:8000"
    volumes:
      - mc2-gt-uploads:/code/uploads
volumes:
  mc2-gt-pg:
    name: mc2-gt-pg
  mc2-gt-uploads:
    name: mc2-gt-uploads

ثم ننشئ الحساب والمنظمة والفريق ومشروع Android ومفتاح API بسكربت يعمل داخل GlitchTip، حتى تكون التجربة قابلة للتكرار:

# Creates the lab user, organization, team, the Android project and an API token (run with: manage.py shell < setup_glitchtip.py).
from apps.users.models import User
from apps.organizations_ext.models import Organization, OrganizationUser, OrganizationUserRole
from apps.projects.models import Project, ProjectKey
from apps.teams.models import Team
from apps.api_tokens.models import APIToken

user = User.objects.create_user(email="[email protected]", password="lab-only-Passw0rd")
org = Organization.objects.create(name="lab")
org.add_user(user, role=OrganizationUserRole.OWNER)
team = Team.objects.create(slug="mobile", organization=org)
team.members.add(OrganizationUser.objects.get(organization=org, user=user))
p = Project.objects.create(name="shop-android", organization=org, platform="android")
team.projects.add(p)
key = ProjectKey.objects.filter(project=p).first() or ProjectKey.objects.create(project=p)
print("dsn", key.get_dsn())
token = APIToken.objects.create(user=user, label="lab")
token.add_permissions(["project:read", "project:write", "project:admin", "project:releases",
                       "event:read", "event:write", "event:admin", "org:read"])
token.save()
print("token", token.token)
docker compose up -d
docker exec -i mc2-gt-web ./manage.py shell < setup_glitchtip.py
dsn http://7d904ab9…@127.0.0.1:43500/1
token ccfd6624…

ال DSN يدخل في التطبيق عند البناء، والتوكن Token سر يبقى في جهاز البناء أو في ال CI ولا يدخل في التطبيق إطلاقاً، لأن أي ملف داخل ال APK يمكن استخراجه.

التطبيق: سعر لا يتوقعه الكود

التطبيق شاشة واحدة فيها سلة بمنتجين، والسعر يصل من ال API نصاً للعرض، والكود يحوله إلى رقم دون أن يتوقع وجود العملة بجانبه. والملف Checkout.kt في الإصدار 1.4.0 كما يلي:

package io.example.shop

data class CartItem(val sku: String, val priceText: String, val qty: Int)

// Prices arrive from the API as display text, e.g. "45" or "120 ر.س".
object PriceParser {
    fun parse(text: String): Int = text.trim().toInt()
}

class Checkout(private val items: List<CartItem>) {
    fun total(): Int = items.sumOf { lineTotal(it) }

    private fun lineTotal(item: CartItem): Int = PriceParser.parse(item.priceText) * item.qty
}

object ReceiptPrinter {
    // Simulates heavy work on the main thread (a big receipt rendered synchronously).
    fun render(seconds: Long) {
        Thread.sleep(seconds * 1000)
    }
}

والشاشة MainActivity.kt تضيف Breadcrumbs بخطوات المستخدم، وتقبل في ال Intent قيمة action حتى نستطيع تشغيل كل حالة من سطر الأوامر دون لمس الشاشة:

package io.example.shop

import android.app.Activity
import android.os.Bundle
import android.widget.Button
import android.widget.LinearLayout
import io.sentry.Breadcrumb
import io.sentry.Sentry
import io.sentry.SentryLevel
import io.sentry.protocol.User

class MainActivity : Activity() {
    private val cart = listOf(
        CartItem("coffee-250g", "45", 2),
        CartItem("mug-ceramic", "120 ر.س", 1),
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Sentry.setUser(User().apply { id = "u-1042" })

        val layout = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
        layout.addView(Button(this).apply { text = "Pay"; setOnClickListener { pay() } })
        layout.addView(Button(this).apply { text = "Print receipt"; setOnClickListener { printReceipt() } })
        setContentView(layout)

        when (intent.getStringExtra("action")) {
            "crash" -> layout.post { pay() }
            "handled" -> layout.post { payHandled() }
        }
    }

    private fun crumb(message: String) {
        Sentry.addBreadcrumb(Breadcrumb().apply {
            category = "checkout"
            this.message = message
            level = SentryLevel.INFO
        })
    }

    private fun pay() {
        crumb("cart opened: ${cart.size} items")
        crumb("coupon applied: RAMADAN10")
        val total = Checkout(cart).total()
        crumb("total: $total")
    }

    private fun payHandled() {
        crumb("cart opened: ${cart.size} items")
        try {
            Checkout(cart).total()
        } catch (e: NumberFormatException) {
            Sentry.captureException(e)
        }
    }

    private fun printReceipt() {
        crumb("receipt print started")
        ReceiptPrinter.render(20)
    }
}

وملف ثالث OrderUpdateReceiver.kt يستقبل تحديث حالة الطلب، كأن يصله من معالج الإشعارات Push، ويطبع الإيصال مباشرة، وسوف نستخدمه لاحقاً في تجربة ال ANR:

package io.example.shop

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import io.sentry.Breadcrumb
import io.sentry.Sentry

// Receives "order shipped" updates (e.g. from a push handler) and prints the receipt.
// onReceive runs on the main thread, so heavy work here freezes the app.
class OrderUpdateReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        Sentry.addBreadcrumb(Breadcrumb().apply {
            category = "orders"
            message = "order update: ${intent.getStringExtra("order")}"
        })
        ReceiptPrinter.render(30)
    }
}

أما ملف AndroidManifest.xml فيحمل ال DSN والبيئة Environment ويسجل ال Receiver:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="Shop"
        android:networkSecurityConfig="@xml/network_security_config">
        <meta-data android:name="io.sentry.dsn" android:value="${sentryDsn}" />
        <meta-data android:name="io.sentry.environment" android:value="lab" />
        <activity android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <receiver android:name=".OrderUpdateReceiver" android:exported="true">
            <intent-filter>
                <action android:name="io.example.shop.ORDER_UPDATE" />
            </intent-filter>
        </receiver>
    </application>
</manifest>

ولأن GlitchTip في التجربة على http وليس https، نحتاج ملف res/xml/network_security_config.xml يسمح بالاتصال غير المشفر إلى 127.0.0.1 فقط، والسبب أن Android 9 وما بعده يمنع الاتصال غير المشفر Cleartext افتراضياً كما في توثيق إعدادات أمان الشبكة:

<?xml version="1.0" encoding="utf-8"?>
<!-- Lab only: GlitchTip runs on http://127.0.0.1:43500 (adb reverse). Production uses HTTPS and needs none of this. -->
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">127.0.0.1</domain>
    </domain-config>
</network-security-config>

إعداد Gradle وR8 وإضافة Sentry Plugin

الملف settings.gradle.kts يحدد المستودعات Repositories، والملف build.gradle.kts في جذر المشروع يحدد إصدار إضافة Android Gradle Plugin وإضافة Sentry:

pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
rootProject.name = "shop-android"
include(":app")
plugins {
    id("com.android.application") version "9.4.1" apply false
    id("io.sentry.android.gradle") version "6.24.0" apply false
}

وملف التطبيق app/build.gradle.kts في الإصدار 1.4.0 كما يلي، ومعه إعداد رفع ملف ال Mapping إلى GlitchTip كما يقترحه توثيق Sentry:

plugins {
    id("com.android.application")
    id("io.sentry.android.gradle")
}

android {
    namespace = "io.example.shop"
    compileSdk = 36
    defaultConfig {
        applicationId = "io.example.shop"
        minSdk = 26
        targetSdk = 36
        versionCode = 7
        versionName = "1.4.0"
        manifestPlaceholders["sentryDsn"] = System.getenv("SENTRY_DSN") ?: ""
    }
    buildTypes {
        release {
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
            signingConfig = signingConfigs.getByName("debug")
        }
    }
}

sentry {
    org.set("lab")
    projectName.set("shop-android")
    url.set("http://127.0.0.1:43500/")
    authToken.set(System.getenv("SENTRY_AUTH_TOKEN"))
    autoUploadProguardMapping.set(System.getenv("SENTRY_AUTH_TOKEN") != null)
    includeSourceContext.set(false)
    includeDependenciesReport.set(false)
    telemetry.set(false)
}

dependencies {
    implementation("io.sentry:sentry-android:8.61.1")
}

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

  • isMinifyEnabled = true يشغل R8 الذي يحذف الكود غير المستخدم ويغير أسماء الكلاسات والدوال ويدمج الدوال الصغيرة في الدوال التي تستدعيها Inlining، وهذا ما يجعل ال Stack Trace غير مقروء، كما شرحنا في مقال المفاهيم.
  • manifestPlaceholders يأخذ ال DSN من متغير البيئة SENTRY_DSN وقت البناء، فلا يكتب في الكود ولا في Git، وتستطيع أن تبني نسخة لكل بيئة بقيمة مختلفة.
  • إضافة Sentry تولد معرفاً UUID لملف ال Mapping في كل بناء، وتضعه داخل ال APK في ملف sentry-debug-meta.properties، ثم ترفع الملف بالأداة sentry-cli إذا وجدت التوكن، والعنوان url يوجهها إلى GlitchTip بدل sentry.io.
  • telemetry.set(false) يمنع الإضافة من إرسال بيانات استخدامها إلى Sentry، فالهدف أن تبقى البيانات عندك.
  • التوقيع بمفتاح ال Debug للتجربة فقط، وفي الإنتاج تستخدم مفتاح التوقيع الخاص بك.

الآن نبني نسخة ال Release، والبناء الأول على جهاز جديد يستغرق وقتاً أطول لأنه ينزل Gradle والإضافات، وقد استغرق في التجربة 6 دقائق و15 ثانية، والبناء التالي:

export SENTRY_DSN=http://7d904ab9…@127.0.0.1:43500/1
./gradlew assembleRelease
> Task :app:minifyReleaseWithR8
> Task :app:generateSentryProguardUuidRelease
> Task :app:injectSentryDebugMetaPropertiesIntoAssetsRelease UP-TO-DATE
> Task :app:uploadSentryProguardMappingsRelease UP-TO-DATE
BUILD SUCCESSFUL in 1m 11s
55 actionable tasks: 22 executed, 33 up-to-date

والناتج ملف app-release.apk بحجم 3.8MB، وملف app/build/outputs/mapping/release/mapping.txt بحجم 6.4MB، لأنه يحمل خريطة كل الكلاسات بما فيها مكتبة Sentry نفسها. ولنرى ماذا فعل R8 بكلاسات التطبيق:

grep '^io.example' app/build/outputs/mapping/release/mapping.txt
io.example.shop.CartItem -> j:
io.example.shop.Checkout -> l:
io.example.shop.MainActivity -> io.example.shop.MainActivity:
io.example.shop.MainActivity$$ExternalSyntheticLambda1 -> j2:
io.example.shop.MainActivity$$ExternalSyntheticLambda2 -> k2:
io.example.shop.OrderUpdateReceiver -> io.example.shop.OrderUpdateReceiver:
io.example.shop.PriceParser -> R8$$REMOVED$$CLASS$$12:
io.example.shop.ReceiptPrinter -> R8$$REMOVED$$CLASS$$13:

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

  • MainActivity وOrderUpdateReceiver احتفظا بأسمائهما لأن النظام يستدعيهما بالاسم من ال Manifest، أما دوالهما فتغيرت أسماؤها.
  • PriceParser وReceiptPrinter لم يعد لهما وجود كـ Class، لأن R8 دمج دوالهما في الأماكن التي تستدعيهما، فإذا حدث العطل داخل parse فلن تجد في ال Stack Trace المشفر أي إطار Frame باسم قريب منه.

تشغيل المحاكي Emulator وتثبيت التطبيق

نشغل المحاكي دون نافذة، ثم ننتظر حتى يكتمل الإقلاع Boot:

emulator -avd mc2_lab -no-window -no-snapshot -no-audio -no-boot-anim -gpu swiftshader_indirect
adb wait-for-device
adb shell getprop sys.boot_completed

والإقلاع الأول لجهاز افتراضي جديد بالخيار -wipe-data استغرق في التجربة 551 ثانية، أي أكثر من 9 دقائق، لأن النظام يجهز التطبيقات أول مرة، فلا تستعجل وتعتبر أن المحاكي معلق. بعد ذلك نثبت التطبيق، ونربط المنفذ 43500 داخل المحاكي بالمنفذ نفسه على جهازك بالأمر adb reverse، حتى يصل التطبيق إلى GlitchTip على العنوان 127.0.0.1:43500 كما هو في ال DSN:

adb install app/build/outputs/apk/release/app-release.apk
adb reverse tcp:43500 tcp:43500

وقد يتساءل البعض: لماذا لا نستخدم العنوان 10.0.2.2 الذي يصل به المحاكي إلى جهازك؟ والإجابة أن ال DSN وروابط GlitchTip مبنية من GLITCHTIP_DOMAIN، فإذا وضعت فيه 10.0.2.2 فلن تفتح الروابط من متصفحك، ومع adb reverse يبقى عنوان واحد يعمل من المتصفح ومن المحاكي.

العطل الأول Crash: Stack Trace مشفر Obfuscated

نوقف التطبيق إن كان يعمل، ثم نشغله بالقيمة action=crash:

adb shell am force-stop io.example.shop
adb shell am start -W -n io.example.shop/.MainActivity --es action crash
adb logcat -d | grep 'E AndroidRuntime'
E AndroidRuntime: FATAL EXCEPTION: main
E AndroidRuntime: Process: io.example.shop, PID: 10050
E AndroidRuntime: java.lang.NumberFormatException: For input string: "120 ر.س"
E AndroidRuntime: 	at java.lang.Integer.parseInt(Integer.java:790)
E AndroidRuntime: 	at java.lang.Integer.parseInt(Integer.java:908)
E AndroidRuntime: 	at l.o(r8-map-id-173e5e2d13eb93c839607ab493e0258a9ff3d92a893cb8b7d41a28ca56179119:85)
E AndroidRuntime: 	at io.example.shop.MainActivity.b(r8-map-id-173e5e2d13eb93c839607ab493e0258a9ff3d92a893cb8b7d41a28ca56179119:39)
E AndroidRuntime: 	at k2.run(r8-map-id-173e5e2d13eb93c839607ab493e0258a9ff3d92a893cb8b7d41a28ca56179119:70)
E AndroidRuntime: 	at android.os.Handler.handleCallback(Handler.java:959)

وبعد ثوان ظهر العطل في GlitchTip كمشكلة Issue جديدة، والصورة التالية تبين ال Stack Trace كما وصل:

صفحة المشكلة في GlitchTip للاستثناء NumberFormatException مع الرسالة For input string 120 ر.س، والآلية UncaughtExceptionHandler وhandled no، وال Stack Trace فيه java.lang.Integer في parseInt، ثم l في o عند السطر 85، ثم io.example.shop.MainActivity في b عند السطر 39، ثم k2 في run عند السطر 70
العطل وصل كاملاً، ولكن بأسماء مثل l.o وk2.run

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

  • أسماء الملفات صارت r8-map-id-… بدل Checkout.kt، وهذا هو معرف ملف ال Mapping الذي يكتبه R8 في الإصدارات الحديثة، والأسطر 85 و39 و70 ليست أسطر الكود الأصلي وإنما أرقام يحولها ملف ال Mapping إلى الأسطر الحقيقية.
  • لا يوجد إطار باسم PriceParser أو Checkout.total، لأنهما مدمجان داخل l.o.
  • المكتبة أضافت تلقائياً الوسوم Tags: الإصدار [email protected]+7 مبني من اسم الحزمة وversionName وversionCode، والمستخدم u-1042، والبيئة lab، ونوع الجهاز ونسخة Android، والوسم isSideLoaded لأن التطبيق ثبت بـ adb وليس من المتجر.

رفع ملف ال Mapping: هل يعمل مع GlitchTip؟

التوثيق يقول إن إضافة Sentry ترفع ملف ال Mapping في كل بناء، وGlitchTip يقول إنه متوافق مع أدوات Sentry، فلنجرب ذلك أولاً كما هو، ثم ننظر في ما يحدث فعلاً داخل GlitchTip.

الطريقة الأولى: إضافة Gradle Plugin

نضع التوكن في متغير البيئة ونبني مرة أخرى، فيرفع ال Task الأخير الملف:

export SENTRY_AUTH_TOKEN=ccfd6624…
./gradlew assembleRelease
> Task :app:uploadSentryProguardMappingsRelease
> Uploaded 1 missing debug information file
> File upload complete:

  UPLOADED 029baa6c-788a-323d-9c4c-f2d223b940b1 (Proguard mapping)

النتيجة تبدو ناجحة، ولكن عند سؤال GlitchTip عن ملفات الرموز Debug Files في المشروع، ثم النظر في سجله:

curl -s -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
  http://127.0.0.1:43500/api/0/projects/lab/shop-android/files/dsyms/
docker logs mc2-gt-web 2>&1 | grep -E 'Extract metadata|difs_assemble'
[]
Extract metadata error: unsupported object file format
difs_assemble: 

والقائمة فارغة، والسبب أن sentry-cli 3.8.0 المرفق مع الإضافة يرفع الملف على أجزاء Chunks ثم يطلب تجميعها، وGlitchTip يقبل الطلب ويرد بأنه أنشأ الملف، ثم يحاول في مهمة خلفية أن يقرأه كملف رموز للكود الأصلي Native، فيفشل ويكتب الخطأ في السجل فقط، كما في الدالة difs_extract_metadata_from_file في كود GlitchTip 6.2.6. وبالتالي يطبع Gradle كلمة UPLOADED وتبقى أعطالك مشفرة، ولن يخبرك أحد بذلك إلا إذا قرأت سجل GlitchTip.

الطريقة الثانية: ملف ZIP عبر curl

GlitchTip يملك طريقاً آخر للرفع على العنوان نفسه files/dsyms/، يقبل ملف ZIP فيه ملف ال Mapping باسم proguard/<UUID>.txt، وهذا الطريق يقرأ الملف كملف ProGuard ويحفظه. والسكربت upload_mapping.sh يأخذ ال UUID الذي وضعته الإضافة في ال APK، ويجهز الملف ويرفعه:

#!/bin/sh
# Uploads the R8 mapping of the last release build to GlitchTip through the zip endpoint (proguard/<uuid>.txt).
# Usage: GLITCHTIP_TOKEN=... ./upload_mapping.sh   (run from the Android project folder)
set -e
URL=http://127.0.0.1:43500
UUID=$(grep -h 'io.sentry.ProguardUuids=' $(find app/build -name sentry-debug-meta.properties | head -1) | cut -d= -f2)
rm -rf build/mapping-upload && mkdir -p build/mapping-upload/proguard
cp app/build/outputs/mapping/release/mapping.txt "build/mapping-upload/proguard/$UUID.txt"
cd build/mapping-upload
python3 -c "import sys, zipfile; z = zipfile.ZipFile('mapping.zip', 'w', zipfile.ZIP_DEFLATED); z.write(sys.argv[1]); z.close()" "proguard/$UUID.txt"
curl -s -H "Authorization: Bearer $GLITCHTIP_TOKEN" -F [email protected] "$URL/api/0/projects/lab/shop-android/files/dsyms/"
echo
GLITCHTIP_TOKEN=ccfd6624… sh upload_mapping.sh
[{"id": 1, "debugId": "029baa6c-788a-323d-9c4c-f2d223b940b1", "cpuName": "any", "objectName": "proguard-mapping", "symbolType": "proguard", "size": 6425920, "sha1": "dd54c931ca0492e53d9bd7418d320c4706d70fff", "data": {"features": ["mapping"]}, "headers": {"Content-Type": "text/x-proguard+plain"}, "dateCreated": "2026-10-23T09:48:39.035Z"}]

في السكربت أعلاه لاحظ التالي:

  • ملف ال ZIP يجب أن يحتوي ملف ال Mapping وحده بمساره proguard/<UUID>.txt، فإذا أضفت المجلد نفسه كعنصر داخل ال ZIP، كما يفعل الأمر python -m zipfile -c mapping.zip proguard/، فسوف يرفض GlitchTip الملف بالرد {"detail": ""} ورمز 400 دون أي شرح، لأنه يبحث في كل اسم داخل ال ZIP عن هذا النمط.
  • ملف بحجم 6.4MB صار 585KB بعد الضغط، والحد الأقصى للرفع في هذا الطريق 32MB بحسب الكود.

الملف الآن محفوظ، فنشغل العطل مرة أخرى ونتوقع Stack Trace مقروءاً:

adb shell am force-stop io.example.shop
adb shell am start -W -n io.example.shop/.MainActivity --es action crash

ولكن الحدث الجديد وصل إلى المشكلة نفسها بالأسماء المشفرة نفسها l.o وMainActivity.b، وعدد أحداثها صار 2.

لماذا بقي مشفراً؟ الخلل Bug رقم 504

قبل ترجمة أي حدث يبحث GlitchTip عن ملفات الرموز في المشروع، ويستبعد منها ملفات الكود المصدري Source Bundles بشرط على الحقل kind، كما في الدالة event_difs_resolve_stacktrace في الكود نفسه:

difs = DebugInformationFile.objects.filter(project_id=project_id).exclude(
    data__kind__in=["src", "sources"]
)

وملف ال ProGuard المحفوظ ليس فيه الحقل kind أصلاً، وفي PostgreSQL نتيجة المقارنة مع حقل غير موجود هي NULL وليست false، فيخرج الملف من النتيجة مع ملفات الكود المصدري، وبالتالي لا يجد GlitchTip أي ملف Mapping يترجم به. وهذا الخلل مسجل في المشكلة 504 منذ أغسطس 2026، والكود في الفرع الرئيسي master عالجه بشرط يتجاهل الملفات التي ليس فيها kind، ولكن آخر إصدار منشور حتى أكتوبر 2026 هو 6.2.6 من 7 أغسطس، وهو لا يحمل الإصلاح.

والحل المؤقت المقترح في المشكلة نفسها هو إضافة الحقل إلى ملفات ProGuard المحفوظة، والسكربت fix_proguard_kind.py يفعل ذلك:

# Workaround for GlitchTip 6.2.6 (issue #504): ProGuard mappings saved without a "kind" key are skipped at symbolication.
# Run with: manage.py shell < fix_proguard_kind.py   (again after each new upload)
from apps.difs.models import DebugInformationFile

for dif in DebugInformationFile.objects.filter(data__symbol_type="proguard"):
    if "kind" not in dif.data:
        dif.data["kind"] = "debug"
        dif.save(update_fields=["data"])
        print("fixed", dif.name)
docker exec -i mc2-gt-web ./manage.py shell < fix_proguard_kind.py
fixed proguard/029baa6c-788a-323d-9c4c-f2d223b940b1.txt
⚠️
هذا تعديل مباشر في قاعدة بيانات GlitchTip، فخذ نسخة احتياطية قبله، وشغله بعد كل رفع لملف Mapping جديد، أو اجعله خطوة في ال CI بعد سكربت الرفع. وعند صدور إصدار من GlitchTip يحمل الإصلاح، تحقق من سجل التغييرات ثم احذف هذه الخطوة.

نشغل العطل للمرة الرابعة:

adb shell am force-stop io.example.shop
adb shell am start -W -n io.example.shop/.MainActivity --es action crash
ال Stack Trace في GlitchTip بعد الترجمة: java.lang.Integer في parseInt، ثم io.example.shop.PriceParser في parse عند السطر 85، ثم io.example.shop.MainActivity في pay عند السطر 39، ثم io.example.shop.MainActivity في onCreate$lambda$6 عند السطر 70، ثم إطارات android.os.Handler وLooper وActivityThread
أسماء الكلاسات والدوال عادت، وأرقام الأسطر بقيت كما في النسخة المشفرة

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

  • الأسماء عادت: PriceParser.parse في Checkout.kt، وMainActivity.pay، وعنوان المشكلة صار يشير إلى pay بدل b.
  • أرقام الأسطر لم تتغير: 85 و39 و70، والأسطر الحقيقية 7 و44 و28. والسبب في كود الترجمة أنه يكتب السطر الجديد في الحقل lineNo بينما الحقل الذي يحفظه ويعرضه هو lineno، فيضيع السطر الصحيح.
  • الإطاران Checkout.lineTotal وCheckout.total لا يظهران، لأن الكود يأخذ أول نتيجة فقط من الأداة التي تفك الدمج، والإطار المشفر l.o:85 يقابله في الحقيقة ثلاثة إطارات مدمجة.
  • الحدث ذهب إلى مشكلة جديدة وليس إلى المشكلة الأولى، لأن التجميع Grouping يعتمد على الإطارات، والإطارات تغيرت بعد الترجمة، فتبقى الأحداث المشفرة القديمة في مشكلة منفصلة عددها 3، والسبب أن الترجمة تحدث مرة واحدة عند الاستقبال ولا يعاد تنفيذها على الأحداث المحفوظة، لذلك ارفع ملف ال Mapping قبل أن تنشر الإصدار وليس بعده.

والصورة التالية تبين قائمة المشكلات في نهاية التجربة، والمشكلة الأقدم بعدد 3 أحداث هي الأحداث التي وصلت قبل الحل المؤقت:

قائمة المشكلات في مشروع shop-android: ApplicationNotResponding Background ANR بحدثين، ثم ثلاث مشكلات NumberFormatException For input string 120 ر.س، الأولى بحدث واحد والثانية بحدث واحد والأقدم بثلاثة أحداث
العطل نفسه في مشكلتين: قبل الترجمة وبعدها، ومعهما الخطأ غير القاتل وال ANR

الترجمة الكاملة محلياً بأداة Retrace

الحل الذي يعمل دائماً، مهما كانت أداة تقارير الأعطال، هو أداة Retrace من R8 مع ملف ال Mapping نفسه، ولذلك احتفظ بملف ال Mapping لكل إصدار نشرته. استخدمنا ملف r8-9.4.24.jar من مستودع Google Maven، وهو إصدار R8 الذي بنى التطبيق كما في رأس ملف ال Mapping، ووضعنا في trace.txt ال Stack Trace من ال Logcat:

head -2 app/build/outputs/mapping/release/mapping.txt
java -cp r8-9.4.24.jar com.android.tools.r8.retrace.Retrace \
  app/build/outputs/mapping/release/mapping.txt trace.txt
# compiler: R8
# compiler_version: 9.4.24
java.lang.NumberFormatException: For input string: "120 ر.س"
	at java.lang.Integer.parseInt(Integer.java:790)
	at java.lang.Integer.parseInt(Integer.java:908)
	at io.example.shop.PriceParser.parse(Checkout.kt:7)
	at io.example.shop.Checkout.lineTotal(Checkout.kt:13)
	at io.example.shop.Checkout.total(Checkout.kt:11)
	at io.example.shop.MainActivity.pay(MainActivity.kt:44)
	at io.example.shop.MainActivity.onCreate$lambda$6(MainActivity.kt:28)
	at android.os.Handler.handleCallback(Handler.java:959)

وهنا ال Stack Trace كما كتبته: السطر 7 هو text.trim().toInt()، والإطاران المدمجان ظهرا، والسطر 44 في MainActivity هو استدعاء Checkout(cart).total(). فإذا رأيت في GlitchTip أسماء صحيحة وأرقام أسطر لا تطابق الكود، فهذا هو السبب، وال Retrace هو المرجع.

ال Breadcrumbs: ماذا حدث قبل العطل؟

في أسفل صفحة الحدث تجد ال Breadcrumbs، وهي ما سجلته المكتبة قبل العطل:

قائمة ال Breadcrumbs في GlitchTip: device.event بمستوى البطارية 100 وBATTERY_CHANGED، ثم ui.lifecycle بالحالات created وstarted وresumed للشاشة MainActivity، ثم app.lifecycle foreground، ثم checkout cart opened 2 items، ثم checkout coupon applied RAMADAN10
أحداث النظام والشاشة سجلتها المكتبة تلقائياً، وخطوات الدفع سجلناها في الكود

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

  • دورة حياة الشاشة Lifecycle وحالة التطبيق وحالة البطارية سجلتها المكتبة دون أي كود، وبالتالي تعرف أن العطل حدث بعد ثانيتين من ظهور الشاشة وأن التطبيق كان في الواجهة.
  • آخر Breadcrumb هو coupon applied، أما total فلم يسجل لأن العطل حدث قبله، وهذا وحده يحدد مكان المشكلة: بين تطبيق رمز الخصم وحساب المجموع.
  • لا يوجد Breadcrumb للضغط على الزر لأننا شغلنا الحالة من سطر الأوامر، وعند الضغط الفعلي تسجل المكتبة الضغطات تلقائياً.
  • كل ما تكتبه في ال Breadcrumbs يخرج من جوال المستخدم، فلا تضع فيه بريداً أو رقم جوال أو توكن، كما شرحنا في قسم الخصوصية من مقال المفاهيم.

الخطأ غير القاتل Non-fatal

ليس كل خطأ ينهي التطبيق، فالدالة payHandled تلتقط الاستثناء وترسله بالدالة Sentry.captureException ثم يكمل التطبيق عمله، وهذا ما تريده مع الأخطاء التي تعالجها ولكنك تحتاج أن تعرف عددها:

adb shell am force-stop io.example.shop
adb shell am start -W -n io.example.shop/.MainActivity --es action handled

ووصل الحدث كمشكلة جديدة بمستوى error وليس fatal، والآلية Mechanism فيها ليست UncaughtExceptionHandler، والإطارات بعد الترجمة PriceParser.parse(Checkout.kt:85) ثم MainActivity.payHandled(MainActivity.kt:43)، ورقم السطر هنا أيضاً من النسخة المشفرة. ولاحظ أن الخطأ نفسه في الكود صار مشكلتين منفصلتين، لأن الإطارات التي فوقه مختلفة، وهذا طبيعي في التجميع.

عدم الاستجابة ANR

ال ANR يحدث عندما يبقى الخيط الرئيسي Main Thread مشغولاً فلا يستجيب لنقرة خلال 5 ثوان، أو لا ينهي Broadcast Receiver عمله في الوقت المحدد، كما في توثيق Android. ومكتبة Sentry تلتقطه بطريقتين بحسب توثيقها: في Android 10 وما قبله تراقب الخيط الرئيسي بنفسها وترسل إذا تعلق أكثر من 5 ثوان، وفي Android 11 وما بعده تقرأ سجل خروج التطبيق ApplicationExitInfo في التشغيل التالي، والصورة التالية تبين الطريقة الثانية بالأوقات الحقيقية من التجربة:

أربع خطوات: الخيط الرئيسي ينفذ onReceive ثم sleep لمدة 30 ثانية والتطبيق في الخلفية عند 12:57:33، ثم النظام ينهي التطبيق برسالة Killing bg anr دون نافذة للمستخدم عند 12:57:46، ثم سجل الخروج ApplicationExitInfo بالسبب reason=6 ANR ومعه نسخة من الخيوط، ثم في التشغيل التالي يقرأ ال SDK السجل ويرسله بالآلية AppExitInfo عند 13:00:22، وفي الأسفل: إذا تعافى التطبيق ولم ينهه النظام فلا يوجد سجل خروج، وبالتالي لا يصلك شيء من هذه الطريقة
ال ANR يصل بعد أن يعود المستخدم إلى التطبيق، وليس لحظة حدوثه

وقد جربنا أولاً الحالة الأوضح: الدالة printReceipt التي يستدعيها زر Print receipt توقف الخيط الرئيسي 20 ثانية والتطبيق في الواجهة، ونرسل نقرة أثناء ذلك، فسجل النظام ANR in io.example.shop وسبب Input dispatching timed out، ولكن لم يصل أي شيء إلى GlitchTip، لأن الخيط الرئيسي تعافى بعد 20 ثانية ولم ينه النظام التطبيق، فلا يوجد سجل خروج تقرؤه المكتبة. وهذا يعني أن ال ANR الذي يعلق فيه التطبيق ثم يتعافى، أو يختار فيه المستخدم الانتظار Wait، لا يصلك من هذه الطريقة في Android 11 وما بعده.

لذلك نأخذ حالة شائعة في التطبيقات الحقيقية: عمل ثقيل داخل onReceive والتطبيق في الخلفية. نشغل التطبيق، ونضغط زر الرئيسية Home، ثم نرسل التحديث كـ Broadcast بأولوية الواجهة 0x10000000 (FLAG_RECEIVER_FOREGROUND) حتى تكون مهلته قصيرة:

adb shell am start -W -n io.example.shop/.MainActivity
adb shell input keyevent KEYCODE_HOME
adb shell am broadcast -f 0x10000000 -a io.example.shop.ORDER_UPDATE \
  -n io.example.shop/.OrderUpdateReceiver --es order A-7731
adb logcat -d | grep ActivityManager | grep -E 'ANR in|Reason|Killing'
adb shell dumpsys activity exit-info io.example.shop | grep reason | head -1
10-23 12:57:46.116   566 11824 E ActivityManager: ANR in io.example.shop
10-23 12:57:46.116   566 11824 E ActivityManager: Reason: Broadcast of Intent { act=io.example.shop.ORDER_UPDATE flg=0x10400010 cmp=io.example.shop/.OrderUpdateReceiver (has extras) }
10-23 12:57:46.117   566 11824 I ActivityManager: Killing 11513:io.example.shop/u0a210 (adj 700): bg anr
          process=io.example.shop reason=6 (ANR) subreason=0 (UNKNOWN) status=0

النظام أنهى التطبيق بالسبب bg anr دون أي نافذة، وسجل الخروج يحمل السبب ANR. ولم يصل شيء إلى GlitchTip حتى شغلنا التطبيق مرة أخرى:

adb shell am start -W -n io.example.shop/.MainActivity
صفحة المشكلة ApplicationNotResponding Background ANR في GlitchTip بمستوى Fatal، والآلية AppExitInfo وhandled no، ووقت الحدث 12:57:46 بينما First Seen عند 1:00:22، والإطارات java.lang.Thread في sleep ثم io.example.shop.ReceiptPrinter في render عند السطر 47 ثم android.app.ActivityThread في handleReceiver
وقت الحدث 12:57:46، ووصوله 13:00:22 عند التشغيل التالي

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

  • العنوان ApplicationNotResponding: Background ANR والآلية AppExitInfo، والحدث يحمل وقت ال ANR الحقيقي 12:57:46، أما ظهوره في GlitchTip فكان 13:00:22، أي بعد دقيقتين ونصف عندما فتحنا التطبيق، وفي الواقع قد يكون الفارق أياماً إذا لم يفتح المستخدم التطبيق.
  • الإطارات مترجمة بالملف نفسه: ReceiptPrinter.render ثم ActivityThread.handleReceiver، أما OrderUpdateReceiver.onReceive فلا يظهر لأن render مدمجة فيه، وهي النتيجة الأولى فقط من فك الدمج كما ذكرنا. والسطر 47 مشفر، والسطر الحقيقي هو 19 في Checkout.kt.
  • ال Breadcrumbs في هذا الحدث تبين أن الشاشة انتقلت إلى paused ثم stopped والتطبيق إلى background، ثم order update: A-7731، وهذا يكفي لتعرف أن المشكلة في ال Receiver وليست في الشاشة.

والحل في الكود هو أن يخرج العمل الثقيل من onReceive إلى خيط آخر، مثل goAsync() مع Coroutine أو WorkManager، كما في توثيق BroadcastReceiver.

الإصدارات Releases

الآن نصلح المشكلة في الإصدار 1.4.1، فالدالة parse تأخذ الأرقام فقط من النص:

object PriceParser {
    fun parse(text: String): Int = text.filter { it.isDigit() }.toInt()
}

ونرفع versionCode إلى 8 وversionName إلى 1.4.1، ونوقف رفع الإضافة لأنه لا يحفظ شيئاً في GlitchTip 6.2.6، ونعتمد على السكربت:

sentry {
    // GlitchTip 6.2.6 accepts the plugin's upload but does not store the mapping, so we upload it ourselves.
    autoUploadProguardMapping.set(false)
    includeSourceContext.set(false)
    includeDependenciesReport.set(false)
    telemetry.set(false)
}
./gradlew assembleRelease
GLITCHTIP_TOKEN=ccfd6624… sh upload_mapping.sh
docker exec -i mc2-gt-web ./manage.py shell < fix_proguard_kind.py
adb install -r app/build/outputs/apk/release/app-release.apk
[{"id": 2, "debugId": "dcc28cac-9a1d-3ffc-9c35-23e1896e3204", "cpuName": "any", "objectName": "proguard-mapping", "symbolType": "proguard", "size": 6430518, "sha1": "b6cb415a164a28bb2296da68a641e86982e09ecc", "data": {"features": ["mapping"]}, "headers": {"Content-Type": "text/x-proguard+plain"}, "dateCreated": "2026-10-23T10:06:20.081Z"}]
fixed proguard/dcc28cac-9a1d-3ffc-9c35-23e1896e3204.txt
Success

وعند تشغيل action=crash في الإصدار الجديد لم يتعطل التطبيق. ثم كررنا تجربة ال ANR في الإصدار 1.4.1، فوصل الحدث إلى مشكلة ال ANR نفسها وصار عددها 2، والحدث الجديد يحمل الوسم release بالقيمة [email protected]+8، وترجم بأسماء صحيحة رغم وجود ملفي Mapping في المشروع.

وقد يتساءل البعض: كيف يعرف GlitchTip أي الملفين يستخدم؟ والإجابة من الكود أنه لا يطابق ال UUID الذي يرسله التطبيق، وإنما يجرب كل ملفات ProGuard في المشروع ويأخذ النتيجة التي ترجمت أكثر عدد من الإطارات. وهذا نجح في التجربة لأن الإصدارين متقاربان، ولكن مع عشرات الإصدارات يزيد وقت المعالجة مع كل ملف، وهذا مسجل في المشكلة 512، وقد يختار ملفاً من بناء آخر يعطي أسماء خاطئة دون تحذير، وهذه المشكلة نفسها التي جربناها في مقال المفاهيم، لذلك احذف من GlitchTip ملفات الإصدارات التي لم تعد مستخدمة، واحتفظ بنسخها عندك للـ Retrace.

صفحة Releases في GlitchTip فيها إصداران: io.example.shop@1.4.1+8 وio.example.shop@1.4.0+7 مع تاريخ الإنشاء فقط
صفحة الإصدارات: الاسم والتاريخ فقط، دون نسبة الجلسات أو المستخدمين دون أعطال

نسبة المستخدمين دون أعطال Crash-free

في مقال المفاهيم ذكرنا أن أهم رقم لإصدار الجوال هو نسبة المستخدمين دون أعطال Crash-free Users ونسبة الجلسات Crash-free Sessions، وأن الإطلاق التدريجي Staged Rollout يتوقف بناءً عليهما. ومكتبة Sentry في Android ترسل الجلسات Sessions افتراضياً، وتنهي الجلسة بعد 30 ثانية في الخلفية، كما في توثيق الإصدارات، وهذه الجلسات هي مصدر النسبة.

ولكن GlitchTip 6.2.6 يستقبل الجلسات ويتجاهلها، فالأنواع session وsessions في قائمة الأنواع المهملة في كود الاستقبال، ومعها المرفقات Attachments أيضاً، وبالتالي صفحة الإصدارات لا تعرض أي نسبة كما في الصورة أعلاه. فإذا كنت تحتاج هذا الرقم من سيرفرك فاختر من الأدوات أداة تحسب صحة الإصدار Release Health من الجلسات، مثل Sentry المستضاف ذاتياً بكل مكوناته، وإذا بقيت على GlitchTip فاعتمد على Android vitals في Play Console لهذه النسبة.

التنبيه Alert عند ظهور عطل جديد

أنشأنا قبل بداية التجربة قاعدة تنبيه ترسل بريداً عند حدث واحد خلال دقيقة لأي مشكلة:

curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  http://127.0.0.1:43500/api/0/projects/lab/shop-android/alerts/ \
  -d '{"name":"shop-android crashes","timespanMinutes":1,"quantity":1,"uptime":false,"alertRecipients":[{"recipientType":"email","url":""}]}'
{"alertRecipients": [{"id": 1, "recipientType": "email", "url": "", "config": {}, "tagsToAdd": []}], "id": 1, "name": "shop-android crashes", "timespanMinutes": 1, "quantity": 1, "uptime": false}

ولأننا استخدمنا consolemail:// تظهر الرسائل في سجل GlitchTip:

docker logs mc2-gt-web 2>&1 | grep -A1 '^Subject:' | grep -v '^--'
docker logs mc2-gt-web 2>&1 | grep -A26 'Subject: .*Background ANR' | grep -E 'Date:|issues/'
Subject: Error in shop-android: NumberFormatException: For input string:
 &quot;120 =?utf-8?b?2LEu2LMmcXVvdDs=?=
Subject: Error in shop-android: NumberFormatException: For input string:
 &quot;120 =?utf-8?b?2LEu2LMmcXVvdDs=?=
Subject: Error in shop-android: NumberFormatException: For input string:
 &quot;120 =?utf-8?b?2LEu2LMmcXVvdDs=?=
Subject: Error in shop-android: ApplicationNotResponding: Background ANR
From: [email protected]
Date: Fri, 23 Oct 2026 10:01:14 +0000
ApplicationNotResponding: Background ANR http://127.0.0.1:43500/lab/issues/4

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

  • أربع رسائل لأربع مشكلات، وليس لكل حدث، فالأحداث الثلاثة في المشكلة الأولى أرسلت رسالة واحدة، وتكرار ال ANR في الإصدار 1.4.1 لم يرسل رسالة جديدة.
  • عنوان الرسالة على سطرين، والجزء العربي مرمز بصيغة =?utf-8?b?…?= كما هو المعتاد في عناوين البريد، وعند فكه يصبح ر.س&quot;، أي أن علامة الاقتباس تصل إلى عميل البريد مرمزة &quot;، وهي الملاحظة نفسها التي رأيناها مع &#x27; في دليل GlitchTip.
  • رسالة ال ANR خرجت عند 10:01:14 بتوقيت UTC، أي بعد قرابة دقيقة من وصول الحدث عند 10:00:22، لأن قواعد التنبيه تفحص كل دقيقة، فالتأخير الحقيقي هو التشغيل التالي للتطبيق ثم دقيقة.

وشرحنا في دليل GlitchTip التنبيه عبر Webhook إلى Slack أو Discord، والقيد على العناوين الداخلية.

الموارد Resources التي استهلكتها التجربة

الجزءالاستهلاك
GlitchTip (الواجهة والمعالج في Container واحد)364MB ذاكرة، وال Image بحجم 1.08GB
PostgreSQL 18.6108MB ذاكرة، و69MB على القرص بعد التجربة
Valkey9MB ذاكرة
ملفات ال Mapping في GlitchTip13MB لملفين، لأنها تحفظ دون ضغط
المحاكي بنظام Android 152.3GB ذاكرة فعلية Working Set و3.5GB محجوزة Private، و4.3GB للجهاز الافتراضي على القرص، و2.2GB لل System Image
الإقلاع الأول للمحاكي551 ثانية مع -wipe-data
البناء6 دقائق و15 ثانية أول مرة مع التنزيلات، ثم بين 36 ثانية ودقيقتين ونصف
حجم ال APK3.8MB، منها 3.3MB مكتبات Native لمكتبة Sentry لأربع معماريات ABI

والرقم الأخير يستحق الانتباه، فمكتبة sentry-android تحمل معها دعم الكود الأصلي NDK بملفات libsentry.so وlibsentry-android.so لكل معمارية، فإذا نشرت بصيغة AAB فالمتجر يرسل لكل جوال معماريته فقط، أي قرابة 0.9MB، وإذا لم يكن في تطبيقك كود C أو C++ فيمكنك استخدام sentry-android-core دون NDK كما في توثيق Sentry.

قبل الإنتاج Production

  • HTTPS: ضع GlitchTip خلف Reverse Proxy بشهادة TLS، واحذف ملف network_security_config.xml الخاص بالتجربة، فلا يوجد سبب لاتصال غير مشفر من جوالات المستخدمين.
  • ملف ال Mapping في ال CI: في كل بناء Release ارفع الملف بالسكربت ثم شغل الحل المؤقت، واحفظ نسخة من mapping.txt مع ال APK أو AAB نفسه، لأن البناء مرة أخرى قد يعطي ملفاً مختلفاً.
  • التوكن: مفتاح API بالصلاحيات اللازمة للرفع فقط، في أسرار ال CI، ولا يدخل في ال APK.
  • البيانات الشخصية: المعرف u-1042 في التجربة معرف داخلي، ولا ترسل بريد المستخدم أو رقمه، وراجع ما تجمعه المكتبة تلقائياً في توثيق البيانات المجمعة.
  • ال ANR: تذكر أن ال ANR الذي يتعافى منه التطبيق لا يصل عبر ApplicationExitInfo، فراجع Android vitals في Play Console للصورة الكاملة.

مشكلات شائعة وحلولها

  • Gradle يطبع UPLOADED والأعطال مشفرة: في GlitchTip 6.2.6 هذا متوقع، فتحقق بقائمة files/dsyms/، فإذا كانت فارغة فارفع بملف ZIP كما في السكربت.
  • الملف محفوظ والأعطال ما زالت مشفرة: شغل fix_proguard_kind.py، ثم أرسل عطلاً جديداً، لأن الأحداث القديمة لا تترجم مرة أخرى.
  • الرد {"detail": ""} عند الرفع: اسم الملف داخل ال ZIP ليس proguard/<UUID>.txt، أو داخل ال ZIP عنصر للمجلد نفسه.
  • لا يصل شيء من المحاكي: تحقق بالأمر adb reverse --list، فالربط يضيع إذا أعيد تشغيل خادم adb، وفي التجربة حدث ذلك مرتين. والمكتبة تحفظ الأحداث على القرص وترسلها في التشغيل التالي، فبعد إعادة الربط وصل حدثان محفوظان دفعة واحدة.
  • Warning: Activity not started, intent has been delivered to currently running top-most instance.: التطبيق يعمل في الواجهة، فلا تمر القيمة action إلى onCreate، فأوقفه أولاً بالأمر am force-stop.
  • Requested internal only, but not enough space عند التثبيت: قرص الجهاز الافتراضي ممتلئ بتطبيقات سابقة، فأنشئ جهازاً افتراضياً خاصاً بالتجربة بدل حذف ما في جهاز يستخدمه غيرك.
  • GlitchTip يتوقف عند أول تشغيل: إذا ظهر relation "organizations_ext_organization" already exists فقد بدأ ترحيلان في الوقت نفسه بعد إعادة إنشاء ال Container أثناء الترحيل الأول، فاحذف ال Volumes وابدأ مرة أخرى، وإذا ظهر Connection refused فأضف فحص الجاهزية لقاعدة البيانات.

إيقاف التجربة

adb -s emulator-5554 emu kill
docker compose down -v

وإذا أنشأت جهازاً افتراضياً خاصاً بالتجربة فاحذفه من Device Manager، لأنه يأخذ عدة جيجابايت.

الخلاصة

  • تطبيق Android بمكتبة Sentry يرسل أعطاله إلى GlitchTip دون تعديل في المكتبة، مع الإصدار والمستخدم والبيئة وال Breadcrumbs، والأحداث تحفظ على الجهاز وترسل في التشغيل التالي إذا تعذر الإرسال.
  • في GlitchTip 6.2.6 رفع ملف ال Mapping بإضافة Gradle يطبع UPLOADED ولا يحفظ شيئاً، والرفع بملف ZIP يحفظ الملف ولكن الترجمة لا تعمل إلا بعد الحل المؤقت للخلل 504.
  • بعد الحل المؤقت تعود أسماء الكلاسات والدوال، وتبقى أرقام الأسطر مشفرة وتختفي الإطارات المدمجة، لذلك احتفظ بملف ال Mapping واستخدم Retrace للترجمة الكاملة.
  • ال ANR في Android 11 وما بعده يصل في التشغيل التالي، ولا يصل إذا تعافى التطبيق قبل أن ينهيه النظام.
  • GlitchTip لا يحسب نسبة المستخدمين دون أعطال، فإذا كان هذا الرقم أساس قرار الإطلاق عندك فاختر أداة تحسبه.

تدريب عملي

  1. غير autoUploadProguardMapping إلى true في مشروعك مع GlitchTip، ثم تحقق بقائمة files/dsyms/ وبسجل GlitchTip هل حفظ الملف فعلاً.
  2. انقل العمل الثقيل في OrderUpdateReceiver إلى goAsync() مع خيط آخر، وكرر تجربة ال Broadcast، وتأكد أن سجل الخروج لم يعد يحمل السبب ANR.

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

  • أكتوبر 2026: كتابة المختبر واختباره على GlitchTip 6.2.6 ومكتبة Sentry Android 8.61.1 وإضافة Sentry Gradle 6.24.0، مع تطبيق Kotlin على محاكي Android 15.
Could not load content
نشرة عرب رووت | ArabRoot

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

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

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

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