> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# تقارير الأعطال Crash Reports لتطبيق Android مع GlitchTip
- URL: https://arabroot.io/articles/مختبر-اعطال-تطبيق-android-مع-glitchtip/
- Published: 2026-10-22T22:30:00.000Z
- Updated: 2026-10-23T10:37:35.000Z
- Description: تطبيق Android يتعطل عمداً على المحاكي ويرسل أعطاله إلى GlitchTip على سيرفرك، وما الذي يعمل فعلاً عند رفع ملف ال Mapping وما الذي لا يعمل.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الاستضافة الذاتية والتطبيقات, DevOps وCI/CD, الاستضافة الذاتية, GlitchTip

في مقال [تقارير الأعطال Crash Reporting في تطبيقات الجوال](https://arabroot.io/articles/%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D9%84%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-crash-reporting-%D9%81%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84/) ذكرنا أن العطل يكتب على قرص الجوال لحظة حدوثه ويرسل في التشغيل التالي، وأن ال Stack Trace يصلك بأسماء مشفرة حتى ترفع ملف ال Mapping من البناء نفسه، وفي مقال [أدوات تقارير أعطال الجوال](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-crashlytics-%D9%88%D8%A7%D9%84%D8%A8%D8%AF%D8%A7%D8%A6%D9%84/) قارنا 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 محلياً](https://arabroot.io/content/images/2026/10/mobile-crash-lab-01-lab-map.webp)

الحدث يصل من المحاكي، وملف ال 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. والمحاكي يحتاج [تسريع العتاد](https://developer.android.com/studio/run/emulator-acceleration?ref=arabroot.io)، وهو في 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](https://arabroot.io/articles/%D8%AA%D8%AA%D8%A8%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-error-tracking-%D9%85%D8%B9-glitchtip/).

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

## تشغيل GlitchTip

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

```yaml
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: glitchtip@example.com
  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، حتى تكون التجربة قابلة للتكرار:

```python
# 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="lab@example.com", 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)
```

```bash
docker compose up -d
docker exec -i mc2-gt-web ./manage.py shell < setup_glitchtip.py
```

```text
dsn http://7d904ab9…@127.0.0.1:43500/1
token ccfd6624…
```

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

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

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

```kotlin
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` حتى نستطيع تشغيل كل حالة من سطر الأوامر دون لمس الشاشة:

```kotlin
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:

```kotlin
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
<?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 افتراضياً كما في [توثيق إعدادات أمان الشبكة](https://developer.android.com/privacy-and-security/security-config?ref=arabroot.io):

```xml
<?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:

```kotlin
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
rootProject.name = "shop-android"
include(":app")
```

```kotlin
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:

```kotlin
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 غير مقروء، كما شرحنا في [مقال المفاهيم](https://arabroot.io/articles/%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D9%84%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-crash-reporting-%D9%81%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84/#symbolication).
- `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 ثانية، والبناء التالي:

```bash
export SENTRY_DSN=http://7d904ab9…@127.0.0.1:43500/1
./gradlew assembleRelease
```

```text
> 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 بكلاسات التطبيق:

```bash
grep '^io.example' app/build/outputs/mapping/release/mapping.txt
```

```text
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:

```bash
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:

```bash
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`:

```bash
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'
```

```text
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](https://arabroot.io/content/images/2026/10/mobile-crash-lab-02-obfuscated-issue.webp)

العطل وصل كاملاً، ولكن بأسماء مثل l.o وk2.run

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

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

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

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

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

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

```bash
export SENTRY_AUTH_TOKEN=ccfd6624…
./gradlew assembleRelease
```

```text
> Task :app:uploadSentryProguardMappingsRelease
> Uploaded 1 missing debug information file
> File upload complete:

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

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

```bash
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'
```

```text
[]
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](https://gitlab.com/glitchtip/glitchtip-backend/-/blob/v6.2.6/apps/difs/tasks.py?ref=arabroot.io). وبالتالي يطبع Gradle كلمة UPLOADED وتبقى أعطالك مشفرة، ولن يخبرك أحد بذلك إلا إذا قرأت سجل GlitchTip.

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

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

```bash
#!/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 file=@mapping.zip "$URL/api/0/projects/lab/shop-android/files/dsyms/"
echo
```

```bash
GLITCHTIP_TOKEN=ccfd6624… sh upload_mapping.sh
```

```text
[{"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 بحسب [الكود](https://gitlab.com/glitchtip/glitchtip-backend/-/blob/v6.2.6/apps/difs/api.py?ref=arabroot.io).

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

```bash
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` في [الكود نفسه](https://gitlab.com/glitchtip/glitchtip-backend/-/blob/v6.2.6/apps/difs/tasks.py?ref=arabroot.io):

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

وملف ال ProGuard المحفوظ ليس فيه الحقل `kind` أصلاً، وفي PostgreSQL نتيجة المقارنة مع حقل غير موجود هي `NULL` وليست `false`، فيخرج الملف من النتيجة مع ملفات الكود المصدري، وبالتالي لا يجد GlitchTip أي ملف Mapping يترجم به. وهذا الخلل مسجل في [المشكلة 504](https://gitlab.com/glitchtip/glitchtip-backend/-/issues/504?ref=arabroot.io) منذ أغسطس 2026، والكود في الفرع الرئيسي [master](https://gitlab.com/glitchtip/glitchtip-backend/-/blob/master/apps/difs/tasks.py?ref=arabroot.io) عالجه بشرط يتجاهل الملفات التي ليس فيها `kind`، ولكن آخر إصدار منشور حتى أكتوبر 2026 هو 6.2.6 من 7 أغسطس، وهو لا يحمل الإصلاح.

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

```python
# 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)
```

```bash
docker exec -i mc2-gt-web ./manage.py shell < fix_proguard_kind.py
```

```text
fixed proguard/029baa6c-788a-323d-9c4c-f2d223b940b1.txt
```

⚠️

هذا تعديل مباشر في قاعدة بيانات GlitchTip، فخذ نسخة احتياطية قبله، وشغله بعد كل رفع لملف Mapping جديد، أو اجعله خطوة في ال CI بعد سكربت الرفع. وعند صدور إصدار من GlitchTip يحمل الإصلاح، تحقق من سجل التغييرات ثم احذف هذه الخطوة.

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

```bash
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](https://arabroot.io/content/images/2026/10/mobile-crash-lab-03-deobfuscated-issue.webp)

أسماء الكلاسات والدوال عادت، وأرقام الأسطر بقيت كما في النسخة المشفرة

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

- الأسماء عادت: `PriceParser.parse` في `Checkout.kt`، و`MainActivity.pay`، وعنوان المشكلة صار يشير إلى `pay` بدل `b`.
- أرقام الأسطر لم تتغير: 85 و39 و70، والأسطر الحقيقية 7 و44 و28\. والسبب في [كود الترجمة](https://gitlab.com/glitchtip/glitchtip-backend/-/blob/v6.2.6/apps/difs/stacktrace%5Fprocessor.py?ref=arabroot.io) أنه يكتب السطر الجديد في الحقل `lineNo` بينما الحقل الذي يحفظه ويعرضه هو `lineno`، فيضيع السطر الصحيح.
- الإطاران `Checkout.lineTotal` و`Checkout.total` لا يظهران، لأن الكود يأخذ أول نتيجة فقط من الأداة التي تفك الدمج، والإطار المشفر `l.o:85` يقابله في الحقيقة ثلاثة إطارات مدمجة.
- الحدث ذهب إلى مشكلة جديدة وليس إلى المشكلة الأولى، لأن التجميع Grouping يعتمد على الإطارات، والإطارات تغيرت بعد الترجمة، فتبقى الأحداث المشفرة القديمة في مشكلة منفصلة عددها 3، والسبب أن الترجمة تحدث مرة واحدة عند الاستقبال ولا يعاد تنفيذها على الأحداث المحفوظة، لذلك ارفع ملف ال Mapping قبل أن تنشر الإصدار وليس بعده.

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

![قائمة المشكلات في مشروع shop-android: ApplicationNotResponding Background ANR بحدثين، ثم ثلاث مشكلات NumberFormatException For input string 120 ر.س، الأولى بحدث واحد والثانية بحدث واحد والأقدم بثلاثة أحداث](https://arabroot.io/content/images/2026/10/mobile-crash-lab-04-issues-list.webp)

العطل نفسه في مشكلتين: قبل الترجمة وبعدها، ومعهما الخطأ غير القاتل وال ANR

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

الحل الذي يعمل دائماً، مهما كانت أداة تقارير الأعطال، هو أداة [Retrace](https://developer.android.com/tools/retrace?ref=arabroot.io) من R8 مع ملف ال Mapping نفسه، ولذلك احتفظ بملف ال Mapping لكل إصدار نشرته. استخدمنا ملف `r8-9.4.24.jar` من [مستودع Google Maven](https://dl.google.com/android/maven2/com/android/tools/r8/9.4.24/r8-9.4.24.jar?ref=arabroot.io)، وهو إصدار R8 الذي بنى التطبيق كما في رأس ملف ال Mapping، ووضعنا في `trace.txt` ال Stack Trace من ال Logcat:

```bash
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
```

```text
# 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](https://arabroot.io/content/images/2026/10/mobile-crash-lab-05-breadcrumbs.webp)

أحداث النظام والشاشة سجلتها المكتبة تلقائياً، وخطوات الدفع سجلناها في الكود

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

- دورة حياة الشاشة Lifecycle وحالة التطبيق وحالة البطارية سجلتها المكتبة دون أي كود، وبالتالي تعرف أن العطل حدث بعد ثانيتين من ظهور الشاشة وأن التطبيق كان في الواجهة.
- آخر Breadcrumb هو `coupon applied`، أما `total` فلم يسجل لأن العطل حدث قبله، وهذا وحده يحدد مكان المشكلة: بين تطبيق رمز الخصم وحساب المجموع.
- لا يوجد Breadcrumb للضغط على الزر لأننا شغلنا الحالة من سطر الأوامر، وعند الضغط الفعلي تسجل المكتبة الضغطات تلقائياً.
- كل ما تكتبه في ال Breadcrumbs يخرج من جوال المستخدم، فلا تضع فيه بريداً أو رقم جوال أو توكن، كما شرحنا في قسم [الخصوصية](https://arabroot.io/articles/%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D9%84%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-crash-reporting-%D9%81%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84/#privacy) من مقال المفاهيم.

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

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

```bash
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](https://developer.android.com/topic/performance/vitals/anr?ref=arabroot.io). ومكتبة Sentry تلتقطه بطريقتين بحسب [توثيقها](https://docs.sentry.io/platforms/android/configuration/app-not-respond/?ref=arabroot.io): في 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، وفي الأسفل: إذا تعافى التطبيق ولم ينهه النظام فلا يوجد سجل خروج، وبالتالي لا يصلك شيء من هذه الطريقة](https://arabroot.io/content/images/2026/10/mobile-crash-lab-06-anr-flow.webp)

ال 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) حتى تكون مهلته قصيرة:

```bash
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
```

```text
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 حتى شغلنا التطبيق مرة أخرى:

```bash
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](https://arabroot.io/content/images/2026/10/mobile-crash-lab-07-anr-issue.webp)

وقت الحدث 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](https://developer.android.com/reference/android/content/BroadcastReceiver?ref=arabroot.io).

## الإصدارات Releases

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

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

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

```kotlin
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)
}
```

```bash
./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
```

```text
[{"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` بالقيمة `io.example.shop@1.4.1+8`، وترجم بأسماء صحيحة رغم وجود ملفي Mapping في المشروع.

وقد يتساءل البعض: كيف يعرف GlitchTip أي الملفين يستخدم؟ والإجابة من الكود أنه لا يطابق ال UUID الذي يرسله التطبيق، وإنما يجرب كل ملفات ProGuard في المشروع ويأخذ النتيجة التي ترجمت أكثر عدد من الإطارات. وهذا نجح في التجربة لأن الإصدارين متقاربان، ولكن مع عشرات الإصدارات يزيد وقت المعالجة مع كل ملف، وهذا مسجل في [المشكلة 512](https://gitlab.com/glitchtip/glitchtip-backend/-/issues/512?ref=arabroot.io)، وقد يختار ملفاً من بناء آخر يعطي أسماء خاطئة دون تحذير، وهذه المشكلة نفسها التي جربناها في [مقال المفاهيم](https://arabroot.io/articles/%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D9%84%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-crash-reporting-%D9%81%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84/#wrong-mapping)، لذلك احذف من GlitchTip ملفات الإصدارات التي لم تعد مستخدمة، واحتفظ بنسخها عندك للـ Retrace.

![صفحة Releases في GlitchTip فيها إصداران: io.example.shop@1.4.1+8 وio.example.shop@1.4.0+7 مع تاريخ الإنشاء فقط](https://arabroot.io/content/images/2026/10/mobile-crash-lab-08-releases.webp)

صفحة الإصدارات: الاسم والتاريخ فقط، دون نسبة الجلسات أو المستخدمين دون أعطال

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

في مقال المفاهيم ذكرنا أن أهم رقم لإصدار الجوال هو نسبة المستخدمين دون أعطال Crash-free Users ونسبة الجلسات Crash-free Sessions، وأن الإطلاق التدريجي Staged Rollout يتوقف بناءً عليهما. ومكتبة Sentry في Android ترسل الجلسات Sessions افتراضياً، وتنهي الجلسة بعد 30 ثانية في الخلفية، كما في [توثيق الإصدارات](https://docs.sentry.io/platforms/android/configuration/releases/?ref=arabroot.io)، وهذه الجلسات هي مصدر النسبة.

ولكن GlitchTip 6.2.6 يستقبل الجلسات ويتجاهلها، فالأنواع `session` و`sessions` في قائمة الأنواع المهملة في [كود الاستقبال](https://gitlab.com/glitchtip/glitchtip-backend/-/blob/v6.2.6/apps/event%5Fingest/schema.py?ref=arabroot.io)، ومعها المرفقات Attachments أيضاً، وبالتالي صفحة الإصدارات لا تعرض أي نسبة كما في الصورة أعلاه. فإذا كنت تحتاج هذا الرقم من سيرفرك فاختر من [الأدوات](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-crashlytics-%D9%88%D8%A7%D9%84%D8%A8%D8%AF%D8%A7%D8%A6%D9%84/) أداة تحسب صحة الإصدار Release Health من الجلسات، مثل Sentry المستضاف ذاتياً بكل مكوناته، وإذا بقيت على GlitchTip فاعتمد على Android vitals في Play Console لهذه النسبة.

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

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

```bash
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":""}]}'
```

```text
{"alertRecipients": [{"id": 1, "recipientType": "email", "url": "", "config": {}, "tagsToAdd": []}], "id": 1, "name": "shop-android crashes", "timespanMinutes": 1, "quantity": 1, "uptime": false}
```

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

```bash
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/'
```

```text
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: glitchtip@example.com
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](https://arabroot.io/articles/%D8%AA%D8%AA%D8%A8%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-error-tracking-%D9%85%D8%B9-glitchtip/#alerts) التنبيه عبر Webhook إلى Slack أو Discord، والقيد على العناوين الداخلية.

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

| الجزء                                          | الاستهلاك                                                                                                      |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| GlitchTip (الواجهة والمعالج في Container واحد) | 364MB ذاكرة، وال Image بحجم 1.08GB                                                                             |
| PostgreSQL 18.6                                | 108MB ذاكرة، و69MB على القرص بعد التجربة                                                                       |
| Valkey                                         | 9MB ذاكرة                                                                                                      |
| ملفات ال Mapping في GlitchTip                  | 13MB لملفين، لأنها تحفظ دون ضغط                                                                                |
| المحاكي بنظام Android 15                       | 2.3GB ذاكرة فعلية Working Set و3.5GB محجوزة Private، و4.3GB للجهاز الافتراضي على القرص، و2.2GB لل System Image |
| الإقلاع الأول للمحاكي                          | 551 ثانية مع \-wipe-data                                                                                       |
| البناء                                         | 6 دقائق و15 ثانية أول مرة مع التنزيلات، ثم بين 36 ثانية ودقيقتين ونصف                                          |
| حجم ال APK                                     | 3.8MB، منها 3.3MB مكتبات Native لمكتبة Sentry لأربع معماريات ABI                                               |

والرقم الأخير يستحق الانتباه، فمكتبة `sentry-android` تحمل معها دعم الكود الأصلي NDK بملفات `libsentry.so` و`libsentry-android.so` لكل معمارية، فإذا نشرت بصيغة AAB فالمتجر يرسل لكل جوال معماريته فقط، أي قرابة 0.9MB، وإذا لم يكن في تطبيقك كود C أو C++ فيمكنك استخدام `sentry-android-core` دون NDK كما في [توثيق Sentry](https://docs.sentry.io/platforms/android/configuration/using-ndk/?ref=arabroot.io).

## قبل الإنتاج Production

- **HTTPS:** ضع GlitchTip خلف Reverse Proxy بشهادة TLS، واحذف ملف `network_security_config.xml` الخاص بالتجربة، فلا يوجد سبب لاتصال غير مشفر من جوالات المستخدمين.
- **ملف ال Mapping في ال CI:** في كل بناء Release ارفع الملف بالسكربت ثم شغل الحل المؤقت، واحفظ نسخة من `mapping.txt` مع ال APK أو AAB نفسه، لأن البناء مرة أخرى قد يعطي ملفاً مختلفاً.
- **التوكن:** مفتاح API بالصلاحيات اللازمة للرفع فقط، في أسرار ال CI، ولا يدخل في ال APK.
- **البيانات الشخصية:** المعرف `u-1042` في التجربة معرف داخلي، ولا ترسل بريد المستخدم أو رقمه، وراجع ما تجمعه المكتبة تلقائياً في [توثيق البيانات المجمعة](https://docs.sentry.io/platforms/android/data-management/data-collected/?ref=arabroot.io).
- **ال 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` فأضف فحص الجاهزية لقاعدة البيانات.

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

```bash
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.