SODA Engineering Blog
🧐

Flutter x OpenTelemetry 入門

2024/05/20に公開

OpenTelemetryとは

OpenTelemetryは、ソフトウェアアプリケーションやサービスのパフォーマンスと健全性を監視し分析するためのオープンソースプロジェクトです。このプロジェクトは、テレメトリー(トレース、メトリクス、ログ)を収集、処理、転送するための一連のAPI、ライブラリ、ツールを提供します。

https://opentelemetry.io/

モバイルアプリ運用でよくある課題

FlutterアプリではエラーログをFirebase Crashlyticsに送っていて、サーバーのAPI実装ではDatadogやCloudWatch Logsにそれぞれログを送信しているケースは多いのではないでしょうか?
それぞれのアプリケーションが別々の場所にログを保存しているのでいざユーザーからの問い合わせが来ても、リクエスト単位でユーザーの行動を追うのは結構難しいのではないかと思います。(実際それぞれのログサービスでフィルターしてというのは結構大変。。)

分散トレーシング

分散トレーシングは、複数のマイクロサービスやシステムを跨いで行われる一連のリクエストを追跡し、可視化する技術です。日々複雑化していくアプリケーションにおいて、どのサービスがどれだけの時間を消費しているか、また問題が発生した際にその原因を特定する手助けをします。

アプリのユーザー行動のログがサーバーAPIログとも紐づけることができればよりアプリケーションの状態を把握することに役立ってくれそうです。今までは点で追っていたログを処理の一連の流れで追うことができれば運用も改善活動にも貢献してくれそうです。

OpenTelemetryの基本要素

OpenTelemetryにはいくつかの要素がありこの記事の中で扱うものをいくつか紹介します。
FlutterでOpenTelemetryを実装する場合下記のパッケージを使う必要があるので詳細についてはこちらを確認してみてください。

https://pub.dev/packages/opentelemetry

OpenTelemetry自体について詳細を知りたい場合は下記がドキュメントになるのでこちらから確認できます。

https://opentelemetry.io/docs/

Exporter

エクスポーターは収集したテレメトリーをコンソール上に出力するか、後述するコレクターに渡してバックエンドサーバーに送信するかを設定することができます。
まず最初は ConsoleExporter だけを設定し収集したいテレメトリーが正しく出力されるか確認していくのがもっとも手軽だと思います。

  // コンソール上に出力する
  final consoleExporter = otel_sdk.ConsoleExporter();

  // バックエンドサーバーに送信する
  final httpExporter = otel_sdk.CollectorExporter(Uri.parse('http://127.0.0.1:4318/v1/traces'));

Collector

コレクターは収集したデータをJaegerなどのバックエンドサーバーに送信してくれるものです。

コレクターを使ってバックエンドサーバーに送信する場合には下記のように processor を作成しておきます。

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

final exporter = otel_sdk.ConsoleExporter();
final processor = otel_sdk.SimpleSpanProcessor(exporter);

TraceとSpan

OpenTelemetryで収集していくテレメトリーは TraceSpan の階層構造になっています。
Traceは、システムを横断する一連の操作やリクエストの完全なパスを表します。これにより、一つのリクエストがどのサービスを経由し、どのようなプロセスを通じて行われたかを追跡することができます。

SpanはTraceの中の個別の論理的な作業単位です。各Spanは特定の操作に対応し、開始時間、終了時間、メタデータ、操作名、そして他のスパンとの関連(親子関係)を持ちます。スパンは階層的に組み込まれ、一つのSpanが別のSpanを呼び出すことによってTrace全体が形成されます。

Traceを作成するためのProviderの実装例を説明していきます。
上記で作成した processorTracerProviderBase に渡しつつ、resourceに設定値を入れていきます。Resourceのキー名は決まっているようで service.name のみ required なパラメタになっているのでトレースしたいサービス名(ここではアプリが判別できる情報とする)を入れておくと良さそうです。

https://opentelemetry.io/docs/specs/semconv/resource/

また、 processor を複数受け取れる構造になっているので最初はデバッグのためにもコンソール出力を有効にしておくと確認が簡単になると思います。

注意する点としては TracerProviderBase の作成はアプリのライフサイクルに対して1度しか呼び出しを許容されていないのでProviderにする際は注意が必要です。

(keepAlive: true)
TracerProvider tracer(TracerRef ref) {
  final consoleExporter = otel_sdk.ConsoleExporter();
  final httpExporter =
      otel_sdk.CollectorExporter(Uri.parse('http://127.0.0.1:4318/v1/traces'));

  final p1 = otel_sdk.SimpleSpanProcessor(consoleExporter);
  final p2 = otel_sdk.SimpleSpanProcessor(httpExporter);

  return otel_sdk.TracerProviderBase(
    processors: [p1, p2],
    resource: otel_sdk.Resource([
      Attribute.fromString('service.name', 'snkrdunk_jp'),
      Attribute.fromString('service.version', '1.0.0'),
      Attribute.fromString('telemetry.sdk.language', 'dart'),
    ]),
    sampler: const otel_sdk.AlwaysOnSampler(),
  );
}

Span

SpanはTraceの中の複数の作業ブロックです。上記のコードを利用して Trace を作成して Span を作成する準備はできているはずなので実際に Span を定義していきます。

Topページで複数のAPIからデータを取得してStateを返すというProviderがあった場合を想定して疑似コードを用意してみます。

Traceには渡す名前は下記のようにコメントが用意されてあり、調べてみると instrumentation name というのはパッケージ名をつけたりすることが推奨されているようなので呼び出し元のファイル名になるように設定しておきます。

[name] should be the name of the tracer or instrumentation library.

Traceを作成したら startSpan() からメソッド名や処理名を渡してSpanを開始していきます。Spanには attributes でパラメタをセットすることができるので必要な情報を適宜していくこともできます。記録したい処理が完了したら end() でSpanを終了します。


class TopPageController extends _$TopPageController {
  
  Future<List<TopPageState>> build() async {
    final instrumentation = 'snkrdunk/controllers/top_page_controller.dart';
    final tracer =
        ref.watch(getTracerControllerProvider());
    final buildSpan = tracer.startSpan(
      'build',
    );
    final recommendItems = await _fetchRecommendItems();
    final newItems = await _fetchNewItems();

    buildSpan.end();

    return TopPageState(
      recommendItems: recommendItems,
      newItems: newItems,
    );
  }

Context

Contextには収集したテレメトリーデータを保存し、伝搬する役割があります。例えば、リクエストを受信してSpanが開始された時、その子Spanを作成するコンポーネントが利用できなければなりません。またFlutterコード内でSpanを引数として直接パスしていかなくても Context から現在のSpanの情報やSpanに関連するSpanを開始したりすることができます。

Spanを階層構造として記録する場合、 Context を利用することで直近の親のSpan情報を利用したりすることができます。 pub.dev のドキュメントには withContext() に関する記述がありますがライブラリの中に実装にはないので Context.current.withSpan を使った例です。

  Future<List<String>> _fetchRecommendItems() async {
    return await Context.current.withSpan(Context.current.span).execute(
      () async {
        try {
          final span = ref
              .read(getTracerControllerProvider('top_page_controller'))
              .startSpan('_fetchRecommendItems');
          final recommentItems =
              await ref.watch(recommendItemsControllerProvider.future);
          span.end();
          return recommentItems;
        } catch (e) {
          Context.current.span
            ..setStatus(StatusCode.error, '_fetchRecommendItems.error')
            ..recordException(e);
          return [];
        }
      },
    );
  }
}

アプリのリクエストとサーバーのContextを繋げる

アプリからトップページを表示したらいくつかの処理を経て、サーバーにAPIリクエストを行うと思います。サーバー側でも処理のSpanを設定したらアプリからのリクエストとサーバーの処理が紐づいた形で処理のログを追いたいですよね。
そのアプリとサーバーのログを紐づけるための情報が Trace Context ヘッダーです。 traceparenttracestate ヘッダーの2つがあり、traceparent には親のトレースの情報が tracestate には送信元のシステムが利用しているベンダー情報が付与されます。

https://qiita.com/sukatsu1222/items/82819461921deba761b9

下記が Dio をHttpClientとして利用している場合にインターセプターでtraceヘッダーを差し込むためのサンプルです。

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

class W3CTraceContextInterceptor extends Interceptor {
  W3CTraceContextInterceptor();

  /// AppとBEの間でTraceContextをやり取りするためのInterceptor
  ///
  /// https://www.w3.org/TR/trace-context/
  
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final carrier = <String, String>{};

    W3CTraceContextPropagator().inject(
      Context.current,
      carrier,
      TraceHeaderInjector(),
    );

    if (carrier['traceparent'] != null) {
      options.headers['traceparent'] = carrier['traceparent'];
    }
    if (carrier['tracestate'] != null) {
      options.headers['tracestate'] = carrier['tracestate'];
    }
    return super.onRequest(options, handler);
  }
}

class TraceHeaderInjector implements TextMapSetter<Map<String, String>> {
  
  void set(Map<String, String> carrier, String key, String value) {
    carrier[key] = value;
  }
}

実際の計測の見え方

Providerの中のいくつかの処理の間にSpanを作成してログを出力した時の例がこのようになります。各Spanでどれくらいの処理時間がかかっているのか、そのSpanの中でどのようなパラメタで実行されたのかなど情報を追うことができるようになります。エラーが起きた時に例外を記録しておくこともできるのでどの処理で失敗し、その前にどのようなパラメタを持っていたのか調査する際に役に立ちそうでした。

さらにAppのトレースとサーバーのTraceを関連付けできた場合は下記のようになるはずです。アプリがAPIリクエストを行いそのリクエストを受け取ったサーバーの処理が記録されています。これをうまく活用できればユーザーのリクエスト単位でアプリとサーバーのログを追うことができそうです。

まとめ

FlutterアプリでOpenTelemetryを使ったデータの収集・送信を行ってみました。opentelemetryパッケージを使った実装では一つ一つTraceやSpanの作成を実装していく必要がありますが、Crashlyticsでは点でしか追えなかったログが前後の処理の関連性を持った形で追うことができるようになりました。

今回はパナさんとアプリ・サーバー分担して検証を行いました。今回は触れなかったサーバー側の実装内容の詳細についてはパナさんが解説してくれます!!乞うご期待!

参考資料

https://blog.cybozu.io/entry/2023/04/12/170000

https://techblog.gaudiy.com/entry/2022/12/20/120428

https://www.w3.org/TR/trace-context/

SODA Engineering Blog
SODA Engineering Blog

Discussion