O bug que não era meu: como observability no Flutter me salvou de horas brigando com a API do cliente

Todo mobile dev já viveu essa cena. O app quebra em produção, o cliente manda print da tela vermelha, e começa o velho jogo de empurra-empurra: o backend jura que a API está perfeita, você jura que o app está certo, e no meio dos dois tem um usuário que só queria finalizar um pedido.

Spoiler: dessa vez o bug não era meu. Mas eu só consegui provar isso em minutos, e não em horas…. porque o app estava instrumentado com observabilidade de verdade. Deixa eu te contar como foi, porque a lição vale ouro (e custa quase nada pra implementar).

O contexto

Eu estava num app de conexão com um sistema de retaguarda… pedidos, integração, aquela vida real de produção onde cada chamada de API conversa com um ERP que ninguém da equipe mobile controla. O front mandava um POST, esperava um JSON bonitinho de volta, e a tela montava em cima daquele payload.

Funcionava. Até que, em um endpoint específico, só na máquina de um cliente, parou de funcionar. Tela em branco, às vezes exception, às vezes um comportamento esquisito que não dava pra reproduzir no meu ambiente. Aquele tipo de bug que te faz desconfiar até da sua própria existência.

O problema real

Sem observabilidade, esse caso vira um pesadelo previsível:

  • Você não tem o payload exato que chegou na máquina do cliente.
  • Você não sabe se o erro foi rede, parsing, status code ou regra de negócio.
  • Você abre um chamado com o backend dizendo “tá dando erro”, e ele responde “aqui funciona”. E nenhum dos dois está mentindo.

A informação que resolve tudo, o que de fato trafegou naquela chamada, está exatamente onde você não consegue chegar: no celular de alguém, do outro lado do país.

É aqui que a maioria dos times perde horas. Não consertando o bug. Descobrindo o bug.

A solução: instrumentar antes da dor

A boa notícia é que esse cenário é totalmente evitável, e foi por isso que eu criei o observability_flutter. A ideia do package é simples e meio chata de propósito: uma API única pra logging, captura de erro e performance, sem te amarrar a um provider específico. Hoje você manda pro Sentry; amanhã pro Firebase; depois pro seu backend caseiro. O app não precisa saber.

A configuração do Sentry mora num único arquivo, implementando a interface ObservabilityService. Sem mágica:

class SentryObservabilityService implements ObservabilityService {
  final String dns;
  final Map<String, dynamic>? config;

  SentryObservabilityService({required this.dns, this.config});

  @override
  Future<void> initialize() async {
    await SentryFlutter.init((options) {
      options.dsn = dns;
      options.environment = kDebugMode ? 'development' : 'production';
      options.tracesSampleRate = config?['tracesSampleRate'] ?? 1;
      options.enableLogs = config?['enableLogs'] ?? true;
    });
  }

  // setUser, recordError, startTransaction, addBreadcrumb...
}

E o registro no boot do app é igualmente sem cerimônia:

await ObservabilityManager.instance.initialize(providers: [
   DebugObservabilityService(),                 // logs no console em dev  SentryObservabilityService(dns: 'https://...@sentry.io/...'), ],);

Pronto. A partir daqui, qualquer lugar do app fala com ObservabilityManager.instance e não precisa importar Sentry em canto nenhum. Seu código de domínio fica limpo, e o provider é detalhe de infraestrutura… assim como deveria ser.

O pulo do gato: transaction no rest client

Configurar o Sentry é metade da história. A outra metade, a que realmente salvou meu dia, foi colocar uma transaction envolvendo cada chamada HTTP. Eu fiz isso no client Dio, no método post:

final transaction = ObservabilityManager.instance.startTransaction(
  name: path,
  operation: 'post',
);

try {
  final response = await _dio.post(
    path,
    data: data,
    queryParameters: queryParameters,
    options: Options(headers: headers, contentType: contentType),
  );

  // o payload da resposta vai junto com o trace
  if (response.data is Map) {
    transaction.finish(data: response.data);
  } else {
    transaction.finish();
  }

  return _dioResponseConverter(response, data, requestType ?? 'application/json');
} on DioException catch (e, s) {
  transaction.finishWithError(e, stackTrace: s); // erro + stacktrace anexados
  _throwRestClientException(e, s);
} catch (ee, ss) {
  transaction.finishWithError(ee, stackTrace: ss);
  rethrow;
}

Repare no detalhe que muda tudo: no caminho feliz, o response.data vai anexado ao transaction.finish(data: ...). No caminho triste, o erro e o stacktrace vão no finishWithError. Ou seja, toda chamada, bem ou mal sucedida, deixa um rastro completo no Sentry: rota, operação, payload de resposta e, quando quebra, a exception com contexto.

São umas 15 linhas. E foram essas 15 linhas que transformaram “não sei o que aconteceu” em “sei exatamente o que aconteceu, e aqui está a prova”.

O desfecho: o trace que acabou com a discussão

Quando o tal cliente reportou o erro, eu não pedi print, não pedi pra ele reproduzir passo a passo, não marquei call. Abri o Sentry, filtrei pela rota e achei a transaction daquela chamada. Lá estava o payload real que o backend devolveu naquela máquina, naquele momento.

E o JSON estava diferente. Um campo que deveria vir como número estava vindo como null em uma condição específica de cadastro, algo que só acontecia com os dados daquele cliente. O app quebrava no parsing, com toda razão, porque recebia algo que o contrato dizia que nunca viria.

Peguei o trace, copiei o payload, mandei pro dev do backend. Sem acusação, sem “é culpa sua”: só o dado cru. Em vez de duas horas de reunião defendendo território, foram dez minutos resolvendo o problema juntos. Ele ajustou a regra no servidor, eu blindei o parsing no app, e seguimos a vida.

Esse é o ponto que eu mais gosto de bater: observabilidade não serve só pra você se defender. Serve pra time inteiro convergir mais rápido numa solução, porque todo mundo está olhando o mesmo fato em vez de discutir suposições.

A real

Eu sei como é. No meio do sprint, “instrumentar o app” sempre parece aquela tarefa que dá pra empurrar pra depois. Aí vem o incidente em produção, o cliente irritado, o backend dizendo que está tudo certo do lado dele, e você abrindo a porta do print() no meio da madrugada tentando adivinhar o que o servidor mandou.

Não precisa ser assim. O custo de instrumentar é ridículo perto do custo de debugar no escuro: um arquivo de provider, uma chamada no boot, e umas linhas de transaction no seu rest client. Depois disso, todo bug de integração já nasce com prova anexada.

Se você não quer reinventar essa estrutura, o observability_flutter já entrega a API única, o suporte a múltiplos providers e o modelo de transaction prontos. É só plugar o Sentry (ou o que você quiser) e começar a dormir mais tranquilo.

Faça um favor pra você do futuro: instrumente antes do incidente. O bug vai aparecer de qualquer jeito — a única escolha que você tem é se vai ter a prova na mão ou não.

Fontes