Kronologi Insiden dan Dampak Google Play Vitals

Lonjakan Application Not Responding (ANR) pasca rilis merupakan salah satu regresi paling berisiko pada ekosistem Android. Google Play Console menetapkan ambang batas perilaku buruk (bad behavior threshold) untuk metrik ANR sebesar 0,47% untuk keseluruhan sesi pengguna (overall) dan 8% untuk metrik per perangkat. Pelanggaran terhadap ambang batas ini secara langsung menurunkan peringkat visibilitas aplikasi pada hasil pencarian dan rekomendasi Play Store.

Pada insiden tipikal pasca peluncuran versi baru (misalnya rilis bertahap 20%), metrik user-perceived ANR rate melonjak drastis, sering kali mencapai angka di atas 1,5%. Reaksi pertama yang wajib dieksekusi oleh tim engineering adalah melakukan mitigasi cepat melalui Google Play Console:

  1. Buka Release overview di Google Play Console.
  2. Pilih track rilis yang bermasalah (Production / Staged Rollout).
  3. Klik Halt rollout secara langsung untuk menghentikan distribusi update ke pengguna baru. Tindakan ini membatasi paparan bug hanya pada persentase pengguna yang telah mengunduh versi terdampak.

Instrumentasi Observabilitas: Mendeteksi Freeze dengan ANR Watchdog

Alat monitoring JavaScript standar (seperti library crash-reporting JS konvensional) tidak dapat mencatat ANR dengan akurat. Pada React Native, thread JavaScript berjalan terpisah dari thread antarmuka Android (main/UI thread). Jika main thread terkunci sementara thread JS tetap merespons, runtime JS tidak mendeteksi crash, tetapi sistem operasi Android akan memicu dialog ANR setelah InputDispatcher gagal mendistribusikan touch event dalam 5 detik.

Untuk merekam stack trace native secara deterministik sebelum sistem mematikan proses, pasang mekanisme ANR Watchdog pada layer native Android. Watchdog berjalan pada thread terpisah dan secara berkala mengirimkan runnable ke Looper.getMainLooper().

package com.yourapp.monitoring

import android.os.Handler
import android.os.Looper
import android.util.Log

class AnrWatchdog(private val timeoutMs: Long = 5000) : Thread("anr-watchdog-thread") {
    private val mainHandler = Handler(Looper.getMainLooper())
    @Volatile private var tick: Long = 0

    override fun run() {
        while (!isInterrupted) {
            val lastTick = tick
            mainHandler.post { tick = (tick + 1) % Long.MAX_VALUE }

            try {
                sleep(timeoutMs)
            } catch (e: InterruptedException) {
                return
            }

            // Jika nilai tick tidak berubah setelah 5000ms, Main Thread terblokir
            if (tick == lastTick) {
                val mainThread = Looper.getMainLooper().thread
                val stackTrace = mainThread.stackTrace
                logAnrIncident(stackTrace)
            }
        }
    }

    private fun logAnrIncident(stackTrace: Array<StackTraceElement>) {
        val message = StringBuilder("ANR Terdeteksi! Main thread freeze lebih dari $timeoutMs ms:
")
        for (element in stackTrace) {
            message.append("	at $element
")
        }
        Log.e("ANRWatchdog", message.toString())
        // Kirim stackTrace ke platform monitoring (Sentry / Crashlytics / Bugsnag)
    }
}
Catatan: Inisialisasi watchdog pada metode onCreate() di MainApplication.kt. Pastikan pelaporan exception native dikonfigurasi agar tidak memblokir thread watchdog itu sendiri.

Analisis Akar Masalah: Pemanggilan Sinkron JSI dan Native Module

Berdasarkan dump stack trace dari Google Play Vitals dan native watchdog, sumber freeze paling sering berakar dari pemanggilan operasi berat yang dieksekusi langsung di main thread. Dalam arsitektur React Native baru (Fabric/TurboModules via JSI) maupun arsitektur Bridge klasik, integrasi native sering disalahgunakan dengan menjalankan parsing data besar atau I/O disk sinkron.

Contoh skenario: Sebuah native module dipanggil via JSI untuk mendekripsi atau mem-parsing cache payload JSON sebesar 15MB saat inisialisasi modul. Jika metode tersebut ditandai dengan eksekusi sinkron atau dipanggil langsung dari konteks UI tanpa thread switching, Looper Android tidak dapat memproses Choreographer.doFrame() atau touch dispatch.

// CONTOH KODE SALAH: Menjalankan parsing sinkron di thread pemanggil / UI Thread
@ReactMethod(isBlockingSynchronousMethod = true)
fun parseLocalCache(rawPayload: String): WritableMap {
    // Blocking CPU-intensive operation langsung di thread pemanggil
    val parsedData = HeavyCryptoUtils.decryptAndParse(rawPayload)
    return Arguments.makeNativeMap(parsedData)
}

Ketika JavaScript runtime memanggil metode di atas dari main thread (atau saat JavaScript thread memblokir thread UI menunggu balasan JSI sinkron), input event sistem ditolak dan OS memunculkan status ANR setelah threshold 5000ms terlewati.

Remediasi Teknis: Memindahkan Komputasi ke Background Thread

Langkah perbaikan wajib menghilangkan seluruh operasi I/O dan komputasi CPU intensif dari Looper.getMainLooper(). Ubah metode sinkron menjadi asynchronous menggunakan Kotlin Coroutines atau Java ExecutorService, lalu kembalikan hasilnya ke JavaScript menggunakan Promise.

package com.yourapp.modules

import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class OptimizedCacheModule(reactContext: ReactApplicationContext) :
    ReactContextBaseJavaModule(reactContext) {

    override fun getName() = "OptimizedCacheModule"

    private val moduleScope = CoroutineScope(Dispatchers.Default)

    @ReactMethod
    fun parseLocalCache(rawPayload: String, promise: Promise) {
        // Alihkan beban komputasi berat ke Dispatchers.Default (CPU pool)
        moduleScope.launch {
            try {
                val result = withContext(Dispatchers.Default) {
                    HeavyCryptoUtils.decryptAndParse(rawPayload)
                }
                val nativeMap = Arguments.makeNativeMap(result)
                promise.resolve(nativeMap)
            } catch (e: Exception) {
                promise.reject("ERR_DECRYPT_FAILED", e.message, e)
            }
        }
    }
}

Pada sisi JavaScript, konsumsi modul tersebut secara asynchronous menggunakan await atau struktur Promise biasa:

import { NativeModules } from 'react-native';

const { OptimizedCacheModule } = NativeModules;

export async function loadCachedData(encryptedBlob: string) {
  try {
    const data = await OptimizedCacheModule.parseLocalCache(encryptedBlob);
    return data;
  } catch (error) {
    console.error('Gagal memuat cache:', error);
    throw error;
  }
}

Tindakan Pencegahan: Monitoring Otomatis dan CI Gatekeeper

Agar insiden serupa tidak mencapai tahap produksi, implementasikan dua lapis validasi preventif:

1. Alerting Otomatis Play Developer Reporting API

Konfigurasikan integrasi webhook antara Google Cloud Monitoring / Play Developer Reporting API dengan channel insiden internal (Slack/PagerDuty). Set alert otomatis saat rasio ANR harian menembus 0,35% (margin aman sebelum menyentuh batas 0,47% Google).

2. Pengujian Profiling UI Thread pada Pipeline CI/CD

Jalankan automated end-to-end performance test menggunakan Android Macrobenchmark atau Maestro sebelum artifact di-deploy ke production track. Tangkap metrik frame deadline missed dan periksa logs dari StrictMode Android:

// Pasang di BuildConfig.DEBUG pada MainApplication.kt
if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectDiskWrites()
            .detectNetwork()
            .penaltyLog()
            .penaltyFlashScreen()
            .build()
    )
}

Penggunaan StrictMode mendeteksi akses disk atau network yang tidak sengaja bocor ke UI thread selama fase pengujian lokal dan QA, mematikan potensi freeze sebelum build dikirim ke tahap staged rollout.