Observabilidade no Flutter com Open Telemetry e Elastic APM

Ian OliveiraIan Oliveira
10 min read

O que é Observabilidade?

Observabilidade (ou o11y ) nos permite entender um sistema de fora, permitindo-nos fazer perguntas sem conhecer o funcionamento interno desse sistema, baseado apenas nas suas saídas externas, como logs, métricas e traces.

Com isso podemos solucionar e lidar facilmente com problemas novos (ou seja, "desconhecidos").

Ela oferece monitoramento de desempenho de ponta a ponta, permitindo que as organizações monitorem todo o ciclo de vida de uma aplicação, desde as interações do usuário no front-end até os processos de back-end.

Se você está apenas começando com esse tema, as postagens abaixo ajudarão você a se aprofundar um pouco mais:

O que é Open Telemetry?

OpenTelemetry (OTel) segundo o site: é uma coleção de APIs, SDKs e ferramentas. Use-o para instrumentar, gerar, coletar e exportar dados de telemetria (métricas, logs e rastros) para ajudar você a analisar o desempenho e o comportamento do seu software.

É também um framework de código aberto para instrumentação de código, e muitos dos principais fornecedores de ferramentas de observabilidade o suportam.

Ele é independente de fornecedor, então, se você optar por trocar sua ferramenta de coleta de dados para uma nova de fornecedor diferente, não será prejudicado pois isso significa que ele não está vinculado a nenhum fornecedor específico de monitoramento ou observabilidade.

A ferramenta que eu uso atualmente para coletar os dados se chama Elastic APM e vou falar um pouco dela mais tarde mas você pode usar qualquer outra de sua preferência.

Antes de começar a usar o OpenTelemetry , é importante entender alguns conceitos básicos, como Spans e Traces.

A instrumentação de código com a OpenTelemetry é suportada por muitas linguagens de programação populares como o exemplo abaixo:

Instrumentação manual vs. automática

Existem duas formas de instrumentar uma aplicação com o OpenTelemetry: manual e automática. No caso do Flutter, como ainda não há um SDK oficial, utilizamos a instrumentação manual.

Já em outras stacks, é possível usar a instrumentação automática, que oferece um conjunto de módulos predefinidos e prontos para uso. Com isso, é possível coletar dados de telemetria com pouca ou nenhuma necessidade de alterações no código.

O que é Elastic APM (Application Performance Monitoring)

É uma solução de observabilidade oferecida pelo Elastic para para instrumentalizar e observar aplicações backend ou frontend. Com ela é possível monitorar o desempenho de aplicações em tempo real coletando dados sobre como sua aplicação está se comportando — por exemplo:

  • Quanto tempo as requisições levam para serem processadas.

  • Onde estão os gargalos (funções lentas, chamadas de banco de dados demoradas, etc.).

  • Quais erros estão acontecendo e com que frequência.

  • Como os serviços estão interagindo em uma arquitetura distribuída (ex: microserviços).

Ele possui integração com as principais ferramentas do mercado e inclusive com a Open telemetry.

Agora vamos adicionar nossa aplicação ao elastic, veja o passo a passo abaixo:

1 - No painel do Elastic no canto superior direito clique em Add data:

2 - Depois selecione Application e Open Telemetry:

3 - E por último mais abaixo selecione Open Telemetry novamente:

A variável OTEL_EXPORTER_OTLP_ENDPOINT contém o endpoint para coleta dos dados da sua aplicação e vamos usá-la mais tarde.

Conceitos-Chave do OpenTelemetry

Os exemplos abaixo estão na linguagem Dart.

1. Tracer

  • O objeto principal e um ponto de entrada para criar spans. Um Trace é composto por uma árvore de Spans e oferece uma visão holística do seu sistema.

  • Você pega ele com algo tipo:

    final tracer = tracerProvider.getTracer('meu-serviço');

Você pode também ter vários traces e um pra cada contexto e assim organizar melhor os dados no Elastic, filtrar por tipo de operação ou subsistema, ter granularidade no controle.

Veja o exemplo abaixo:

final apiTracer = tracerProvider.getTracer('api-tracer');
final uiTracer = tracerProvider.getTracer('ui-tracer');

No seu app Flutter, na maioria dos casos, você pode usar apenas um Tracer global que já é o suficiente. Se no futuro seu app crescer muito e tiver múltiplos módulos/libraries com integração separada, aí pode fazer sentido separar Tracers

Apesar de você poder ter vários tracers, o que realmente importa são os spans e atributos que você gera.

final span = tracer.startSpan('api.chamada.login');
span.setAttribute('feature', 'login');
span.setAttribute('http.url', '/auth/login');

2. Span

  • Representa uma unidade de trabalho (ex: carregar dados, enviar mensagem, fazer login…). Spans contêm Eventos , logs estruturados (JSON) que descrevem ocorrências pontuais durante aquele período e podem ser usados para:

    • Medir tempo de tela

    • Performance de chamadas de APIs

    • Acompanhamento de eventos e jornada do usuário

  • Combine com try/catch pra capturar erros:

      final span = tracer.startSpan("fazer-login");
      try {
        await login();
      } catch (e) {
        span.setStatus(StatusCode.error, description: e.toString());
      } finally {
        span.end();
      }
    
  • Um Span pode ter:

    • Nome (operation-flutter) O nome do microsserviço que está sendo executado ou uma chamada de função.

    • Tempo de início/fim

    • Atributos (ex: status, erro, nome do usuário) Lista de pares de chave-valor usados ​​para agregação ou filtragem de dados de rastreamento (por exemplo, identificador do cliente, nome do host do processo). Usados ​​para descrever e contextualizar o trabalho realizado em um Span.

    • Hierarquia (spans filhos)

    • Eventos : Strings com registro de data e hora compostas por registro de data e hora, nome e atributos opcionais usadas para descrever e contextualizar o trabalho realizado em um período.

final span = tracer.startSpan("carregar-dados");
// faz algo
span.end();

Para enriquecer com atributos use o span.setAttribute(...) para dar contexto:

  void _spanWithAttribute() {
    final span = tracer.startSpan(
      'GET /resource/catalog',
      kind: SpanKind.client,
    );
    span.setAttribute(Attribute.fromString('http.method', 'GET'));
    span.setAttribute(Attribute.fromString('http.url', 'your http url'));
    //faça algo
    span.end();
  }

3. Traces

  • É como o OpenTelemetry conecta vários spans (inclusive em diferentes serviços).

  • Permite criar um span filho ligado ao span pai:

Context.current.withSpan(parentSpan).execute(() {
  final child = tracer.startSpan("operação-filho");
  child.end();
});

4. TracerProvider

  • Gerencia os tracers e controla como eles se comportam. Nele você configurar:

    • Processadores de span (ex: batch ou simples)

    • Exportadores (pra onde os dados vão: console, Elastic, etc)

    • Metadata sobre o app (nome do serviço, versão…)

5. Exporter

  • Responsável por enviar os dados dos spans pro backend (Elastic, Zipkin, Jaeger, etc).

  • Ex: CollectorExporter, ConsoleExporter, ou SlsExporter.

6. Span Processor

Eles são responsáveis por processar e exportar os spans assim que eles são criados (ou finalizados).

  • SimpleSpanProcessor: exporta logo após o .end() imediatamente

  • BatchSpanProcessor: agrupa vários e exporta em lote (mais eficiente e indicado para produção)

  • Recomendado: sempre usar BatchSpanProcessor no app real.

  • Exibir no console (SimpleSpanProcessor com ConsoleExporter)

Você pode registrar vários ao mesmo tempo:

final tracerProvider = TracerProviderBase(
  processors: [
    BatchSpanProcessor(exporterElastic),
    SimpleSpanProcessor(ConsoleExporter()),
  ],
);

Neste caso:

  • Um envia os dados pra sua ferramenta que vai receber os dados que nesse caso usamos o Elastic APM

  • Outro imprime os spans no console para debug localmente

7. Resource

  • Metadados sobre o app: nome, versão, ambiente (dev/prod), etc.

  • Isso ajuda a identificar o app no painel de observabilidade.

Resource([
  Attribute.fromString("service.name", "meu_app"),
  Attribute.fromString("deployment.environment", "dev"),
])

8. Metrics e Logs (Futuramente no Flutter)

  • OpenTelemetry também suporta métricas e logs estruturados, mas a maior parte do suporte em Flutter ainda é centrada em traces (spans).

  • A comunidade tá evoluindo isso aos poucos em relação ao package que usamos.

Utilizando o package do open telemetry no Flutter

1— Adicionando a dependência em seu projeto

Vamos começar adicionando a dependência ao nosso arquivo pubspec.yaml.

Instale o pacote a partir da linha de comando com o Flutter:

 $ flutter pub add opentelemetry

Ou adicione opentelemetry diretamente em seu pubspec.yaml e execute flutter pub get no momento da criação desse post ele está na versão 0.18.10 .

dependencies:
 opentelemetry: ^0.18.10

Agora, no seu código Dart, você pode importar o package:

import 'package:opentelemetry/api.dart';
import 'package:opentelemetry/sdk.dart';

2 - Para inicializar o SDK vamos criar um arquivo chamado telemetry_initializer.dart veja como ficou abaixo:

import 'package:opentelemetry/api.dart' as otel_api;
import 'package:opentelemetry/sdk.dart' as otel_sdk;

late otel_api.Tracer tracer;

Future<void> initTelemetry() async {
  final exporter = otel_sdk.CollectorExporter(
    Uri.parse(
      'https://<seu-endpoint>/v1/traces', // 👈 não esqueça de incluir o /v1/traces!
    ),
    headers: {
      'Authorization': 'Bearer <sua-api-key>',
      'Content-Type': 'application/json',
    },
  );

  final simpleProcessor = otel_sdk.SimpleSpanProcessor(exporter);
  final batchProcessor = otel_sdk.BatchSpanProcessor(exporter);

  final tracerProvider = otel_sdk.TracerProviderBase(
    processors: [batchProcessor, simpleProcessor],
    resource: otel_sdk.Resource([
      otel_api.Attribute.fromString('service.name', 'main'),
      otel_api.Attribute.fromString('service.version', '1.0.0'),
      otel_api.Attribute.fromString('deployment.environment', 'dev'),
    ]),
  );

  otel_api.setTracerProvider(tracerProvider);

  tracer = tracerProvider.getTracer('flutter-app');
}

Acima, importamos o TraceProviderBase, que é o ponto inicial do open telemetry, ele fornece acesso ao Tracer, a classe responsável pela criação de spans.

Além disso, especificamos o uso do BatchSpanProcessor ele agrupa spans e os envia em massa.

Isolando a regra da coleta de erros com o padrão Service (error_reporter_service.dart)

A finalidade da camada de serviço, por outro lado, é encapsular a lógica de negócios em um único local para promover a reutilização de código e a separação de interesses.

Basicamente seria isolar uma tarefa específica em um outro objeto sendo assim assumindo uma responsabilidade muito estreita de realizar alguma atividade útil.

Então, vamos criar um serviço para integrar apenas a coleta de erros ao seu projeto Flutter e gerenciar os relatórios de erros. 👇🏻

import 'dart:isolate';

import 'package:flutter/foundation.dart';
import 'package:opentelemetry/api.dart';
import 'package:opentelemetry/sdk.dart';

class ErrorReporterService {
  static void initialize() {
    FlutterError.onError = (FlutterErrorDetails details) {
      _report(
        details.exception,
        details.stack ?? StackTrace.empty,
        'FLUTTER_ERROR',
      );
    };

    Isolate.current.addErrorListener(_isolateErrorListener);
  }

  static Future<void> _report(
    dynamic exception,
    StackTrace stack,
    String tag,
  ) async {
    final tracer = globalTracerProvider.getTracer('flutter-my-app');
    final span = tracer.startSpan('error: $tag');

    span.setStatus(StatusCode.error, exception.toString());
    span
      ..addEvent('date', timestamp: DateTimeTimeProvider().now)
      ..setStatus(StatusCode.error, exception.toString())
      ..recordException(
        exception,
        stackTrace: stack,
        attributes: [
          Attribute.fromString(
            'exception.type',
            exception.runtimeType.toString(),
          ),
          Attribute.fromString('exception.message', exception.toString()),
          Attribute.fromString('exception.stacktrace', stack.toString()),
          Attribute.fromString('error.tag', tag),
        ],
      );

    span.end();

    debugPrint('Erro capturado: $exception');
    debugPrintStack(label: tag, stackTrace: stack);
  }

  static SendPort get _isolateErrorListener {
    return RawReceivePort((pair) async {
      final List errorAndStacktrace = pair;
      final exception = errorAndStacktrace[0];
      final stackTrace = errorAndStacktrace[1] as StackTrace;
      await _report(exception, stackTrace, 'ISOLATE');
    }).sendPort;
  }

  static void reportExternalFailure(
    dynamic exception,
    StackTrace? stack,
    String? label,
  ) {
    if (stack != null && label != null) {
      _report(exception, stack, 'EXTERNAL_FAILURE: $label');
    }
  }
}
  • Isolate.current.addErrorListener(_isolateErrorListener): adiciona um ouvinte de erros para isolados (ou seja, quando múltiplos threads estão rodando em paralelo no app), garantindo que erros ocorridos fora do thread principal sejam capturados e reportados.

Exemplo de uso do ErrorReportService

Essa abordagem centraliza a manipulação de exceções e falhas no aplicativo, garante que erros sejam capturados e enviados para monitoramento via Elastic APM, e oferece flexibilidade para criar diferentes tipos de erros com pouca repetição de código.

import 'package:flutter/foundation.dart';

import '../service/crashlytics_service.dart';

abstract class Failure implements Exception {
  final String errorMessage;

  Failure({
    StackTrace? stackTrace,
    String? label,
    dynamic exception,
    this.errorMessage = '',
  }) {
    // Loga a stack trace apenas no modo debug para evitar poluição de logs em produção.
    if (stackTrace != null && kDebugMode) {
      debugPrintStack(label: label, stackTrace: stackTrace);
    }

    // Reporta o erro ao service se houver exception
    ErrorReport.externalFailureError(exception, stackTrace, label);
  }
}

class UnknownError extends Failure {
  UnknownError({
    String? label,
    dynamic exception,
    StackTrace? stackTrace,
  }) : super(
          stackTrace: stackTrace,
          label: label,
          exception: exception,
          errorMessage:
              'Unknown Error', // Passa a mensagem diretamente para a classe base
        );
}

Possíveis extensões

Se no futuro você quiser criar outras subclasses de Failure para diferentes tipos de erros, será muito fácil. Por exemplo, você pode criar uma classe NetworkError para representar erros relacionados à rede, e utilizar o mesmo padrão de construção.

class NetworkError extends Failure {
  NetworkError({
    String? label,
    dynamic exception,
    StackTrace? stackTrace,
  }) : super(
          stackTrace: stackTrace,
          label: label,
          exception: exception,
          errorMessage: 'Network Error',
        );
}

Visualizando os dados no Elastic APM:

Abaixo na aba Traces veremos todos os erros coletados do nosso aplicativo, se você clicar nele verá vários dados e insights sobre ocorrido:

Conclusão

Bom é isso 😎.

Neste artigo, você aprendeu como configurar e preparar nossos aplicativos Flutter para serem usados ​​com o padrão da Open Telemetry o que é incrível pois é uma ferramenta robusta para coletar e analisar dados.

Aqui está o código do projeto de exemplo no github. 🔗

Espero que você tenha gostado! Compartilhe-o com seus amigos e colegas!

Juntos, vamos construir apps incríveis que transformam o mundo!

Se tiver alguma dúvida ou contribuição, deixe nos comentários!

Me siga para estar sempre por dentro dos próximos artigos 📲 🚀

🌐 Minhas redes sociais 🌐

GitHub | LinkedIn | Instagram | Twitter (X) | Medium

0
Subscribe to my newsletter

Read articles from Ian Oliveira directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ian Oliveira
Ian Oliveira

Apaixonado pela criação de aplicativos móveis, trabalhando ativamente com o Flutter desde de 2018. Bacharel em Ciência da Computação pela UESPI (Parnaíba). Trabalha como Desenvolvedor Flutter Especialista na Appmax. Atua como organizador do GDG Parnaíba e Flutter Piauí.