O app voltou pra splash e o Sentry não viu nada

Você já teve aquele bug que ninguém sabe reproduzir? Acontece em produção, o cliente liga reclamando, você olha o Sentry e… nada. Zero. Silêncio absoluto.

Esse é o pior tipo de bug. Não porque é difícil de resolver — mas porque você nem sabe o que está acontecendo.

No nosso caso, o sintoma era simples e frustrante: ao voltar do app externo de pagamento, o aplicativo de PDV reaparecia na tela de splash/login em vez de retomar de onde estava. O operador precisava fazer login de novo. No meio de um atendimento. Com a fila.

O Sentry não registrava nada porque não era um crash. O app estava vivo. Ele simplesmente tinha esquecido tudo.

O que estava acontecendo (spoiler: o Android te matou)

Quando o app chama um terminal de pagamento externo via startActivityForResult, ele vai para background. O app de pagamento assume o foreground.

Nesse momento, o Android olha para a memória disponível do dispositivo. Se estiver apertado, o processo do seu app é candidato a execução pelo sistema. O Android pode matar o processo para liberar RAM — e isso não gera crash, não gera stack trace, não chega no Sentry. É uma morte silenciosa e educada.

Quando o app de pagamento termina e devolve o resultado, o Android precisa retomar o seu app. Mas o processo não existe mais. Então ele recria do zero — e o savedInstanceState não foi preservado porque a task foi destruída pelo sistema, não pelo usuário. Resultado: a activity sobe nova, a splash aparece.

[app em background]
   ↓
Android: "preciso de memória"
   ↓
Android mata o processo (sem crash, sem trace)
   ↓
App de pagamento termina
   ↓
Android: preciso retomar o app anterior
   ↓
Sem processo → recria do zero
   ↓
Splash/login aparece

Há uma segunda possibilidade: a task é recriada com o processo ainda vivo (recriação da activity por back stack). Nesse caso o PID é o mesmo, mas o estado da UI foi perdido porque não foi persistido corretamente.

E há uma terceira: o launchMode do app de pagamento cria uma task nova, a sua task some do back stack, e o Android abre a intent como nova inicialização. Um launchMode=singleInstance mal configurado do outro lado pode fazer exatamente isso.

Por que o Sentry não vê isso

O Sentry captura crashes, ANRs e eventos explícitos. Ele não vê morte silenciosa de processo porque, quando o processo morre assim, o SDK não tem tempo de rodar — e não há exceção para capturar. Quando o processo ressurge, é uma nova instância do SDK sem contexto do que aconteceu antes.

O sentry_flutter 9.6 integra o AppExitInfo da API 30+ para capturar ANRs históricos, mas o nosso caso de baixa memória não gera um exit reason de ANR — gera REASON_LOW_MEMORY ou REASON_SIGNALED, que aparecem somente se você perguntar explicitamente.

A pergunta certa é: como guardar informação antes da morte e enviá-la depois da ressurreição?

A solução: Kotlin detecta, Dart envia

A ideia é simples: antes de chamar o app de pagamento, grava um marcador em disco. Quando o app sobe de novo, verifica se o marcador existe — se sim, significa que houve restart no meio de um pagamento. Aí você constrói o diagnóstico e manda pro Sentry.

O marcador precisa estar em SharedPreferences porque sobrevive à morte de processo (diferente de variáveis em memória).

Lado Kotlin (MainActivity)

class MainActivity : FlutterActivity() {

    private val channel by lazy {
        MethodChannel(flutterEngine!!.dartExecutor.binaryMessenger, "payment_diag")
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val prefs = getSharedPreferences("payment_diag", Context.MODE_PRIVATE)
        val paymentInProgress = prefs.getBoolean("payment_in_progress", false)

        if (paymentInProgress) {
            // Processo foi recriado durante um pagamento
            val oldPid = prefs.getInt("pid", -1)
            val newPid = android.os.Process.myPid()
            val startTs = prefs.getLong("ts", 0)
            val cnpj = prefs.getString("cnpj", "n/d") ?: "n/d"
            val durationMs = System.currentTimeMillis() - startTs

            val cause = when {
                oldPid != newPid -> "PROCESS_KILLED"
                savedInstanceState == null -> "TASK_RECREATED"
                else -> "UNKNOWN"
            }

            val exitReason = getExitReason()
            val memInfo = getMemoryInfo()

            val diag = mapOf(
                "cause" to cause,
                "pid_old" to oldPid,
                "pid_new" to newPid,
                "exit_reason" to exitReason,
                "low_memory" to memInfo.lowMemory,
                "avail_mem_mb" to (memInfo.availMem / 1024 / 1024).toInt(),
                "is_task_root" to isTaskRoot,
                "intent_action" to (intent?.action ?: "null"),
                "duration_ms" to durationMs,
                "cnpj" to cnpj
            )

            prefs.edit().putString("pending_diag", diag.toString()).apply()
        }

        channel.setMethodCallHandler { call, result ->
            when (call.method) {
                "markPaymentStart" -> {
                    val cnpj = call.argument<String>("cnpj") ?: ""
                    prefs.edit()
                        .putBoolean("payment_in_progress", true)
                        .putInt("pid", android.os.Process.myPid())
                        .putLong("ts", System.currentTimeMillis())
                        .putString("cnpj", cnpj)
                        .apply()
                    result.success(null)
                }
                "clearPaymentMark" -> {
                    prefs.edit()
                        .putBoolean("payment_in_progress", false)
                        .apply()
                    result.success(null)
                }
                "getAndClearDiag" -> {
                    val diag = prefs.getString("pending_diag", null)
                    prefs.edit()
                        .remove("pending_diag")
                        .putBoolean("payment_in_progress", false)
                        .apply()
                    result.success(diag)
                }
                else -> result.notImplemented()
            }
        }
    }

    @RequiresApi(Build.VERSION_CODES.R)
    private fun getExitReason(): String {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return "n/d (API < 30)"
        val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
        val reasons = am.getHistoricalProcessExitReasons(packageName, 0, 1)
        return reasons.firstOrNull()?.let {
            when (it.reason) {
                ApplicationExitInfo.REASON_LOW_MEMORY -> "LOW_MEMORY"
                ApplicationExitInfo.REASON_CRASH -> "CRASH"
                ApplicationExitInfo.REASON_ANR -> "ANR"
                ApplicationExitInfo.REASON_SIGNALED -> "SIGNALED"
                ApplicationExitInfo.REASON_USER_REQUESTED -> "USER_REQUESTED"
                else -> "OTHER(${it.reason})"
            }
        } ?: "n/d"
    }

    private fun getMemoryInfo(): ActivityManager.MemoryInfo {
        val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
        return ActivityManager.MemoryInfo().also { am.getMemoryInfo(it) }
    }
}

Lado Dart (orquestra e envia)

class PaymentDiagService {
  static const _channel = MethodChannel('payment_diag');

  // Chamar antes de iniciar o pagamento
  static Future<void> markStart(String cnpj) async {
    await _channel.invokeMethod('markPaymentStart', {'cnpj': cnpj});
  }

  // Chamar quando o pagamento retorna normalmente
  static Future<void> clearMark() async {
    await _channel.invokeMethod('clearPaymentMark');
  }

  // Chamar após SentryFlutter.init() — verifica se houve restart
  static Future<void> checkAndReport() async {
    final diag = await _channel.invokeMethod<String>('getAndClearDiag');
    if (diag == null) return;

    await Sentry.captureMessage(
      'payment_return_restart',
      level: SentryLevel.warning,
      withScope: (scope) {
        scope.setTag('cause', _extractField(diag, 'cause'));
        scope.setTag('exit_reason', _extractField(diag, 'exit_reason'));
        scope.setTag('low_memory', _extractField(diag, 'low_memory'));
        scope.setContexts('payment_restart_diag', {
          'raw': diag,
        });
      },
    );
  }

  static String _extractField(String diag, String key) {
    // parse simples do toString() do mapa Kotlin
    final match = RegExp('$key=([^,}]+)').firstMatch(diag);
    return match?.group(1)?.trim() ?? 'n/d';
  }
}

No main.dart:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await SentryFlutter.init((options) {
    options.dsn = const String.fromEnvironment('SENTRY_DSN');
    options.sampleRate = 1.0; // sobe para 1.0 — este evento é raro, não pode perder
  });

  // Verifica restart pós-pagamento imediatamente após o init
  await PaymentDiagService.checkAndReport();

  runApp(const MyApp());
}

No fluxo de pagamento:

Future<void> processarPagamento(String cnpj, double valor) async {
  await PaymentDiagService.markStart(cnpj);

  try {
    final resultado = await PosPlugin.iniciarPagamento(valor);
    await PaymentDiagService.clearMark(); // retornou normalmente
    // ... processa resultado
  } catch (e) {
    await PaymentDiagService.clearMark();
    rethrow;
  }
}

O timing que faz tudo funcionar

Existe um detalhe crítico que não é óbvio: no onCreate do Android, o engine do Flutter ainda não inicializou. Isso significa que o SDK do Sentry não está disponível quando o Kotlin detecta o restart.

Por isso o Kotlin só detecta e persiste em disco. O Dart envia depois que o SentryFlutter.init completa. O marcador em SharedPreferences serve como ponte entre os dois mundos — e sobrevive à morte do processo porque está em disco, não em memória.

PROCESSO MORTO → RESURGE → onCreate (Kotlin detecta → grava em disco)
                        → Flutter init → SentryFlutter.init
                        → PaymentDiagService.checkAndReport() → Dart lê e envia

O que você recebe no Sentry

Com isso, cada restart durante um pagamento aparece como evento payment_return_restart com:

  • cause: PROCESS_KILLED (PID diferente), TASK_RECREATED (PID igual, savedInstanceState nulo), UNKNOWN
  • exit_reason: LOW_MEMORY, ANR, CRASH, SIGNALED, USER_REQUESTED (só Android 11+)
  • low_memory: true/false
  • duration_ms: tempo que o usuário ficou no app de pagamento
  • is_task_root: se a activity subiu como root da task (sinal de lançamento limpo)
  • intent_action: MAIN/LAUNCHER = relançado do zero, MAIN sem launcher = task recriada

Com esses dados você finalmente consegue responder: foi memória? Foi o launchMode? Acontece em devices específicos? Em quanto tempo de pagamento?

O problema foi identificado como memória baixa em terminais específicos (hardware limitado rodando uma versão customizada do Android). O launchMode foi a correção para os casos de task recriada pelo back stack; a flag de memória ajudou a identificar os casos de processo morto.

Sem a telemetria, teríamos ficado meses tentando reproduzir em lab um bug que só acontece em campo, sob carga, em dispositivo específico, com memória do sistema apertada.

O Sentry não viu o bug. Nós instrumentamos para que ele visse.