DockerでGrafana Faroを構築:エンドツーエンドのフロントエンドRUMとエラートラッキング

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

課題:フロントエンドパフォーマンスにおける死角

バックエンドのダッシュボード上ではすべてが完璧に見えるかもしれません。APIのレイテンシは45ms未満で安定し、CPU使用率は20%以下を維持、NginxのHTTP 200レスポンス率は99.98%を記録しています。しかし、サポートへの問い合わせは絶えません。モバイル版Safariでチェックアウトボタンが何も起きずに失敗する、SPAの画面遷移がレンダリング途中でフリーズする、ページの動作が重くてユーザーがカートを離脱してしまうなどの問題です。

サーバー側のメトリクスでは、ユーザーのブラウザ内部で何が起きているかを把握できません。キャッチされなかったReactエラーによってコンポーネントツリーがアンマウントされたり、3MBのバナー画像が0.45のCLS(Cumulative Layout Shift)を引き起こしたりしても、バックエンドログには何も記録されません。Grafana Faroは、クライアントブラウザという問題が発生したその場でイベントをキャプチャし、ランタイムログ、パフォーマンス測定値、ネットワークトレースを既存のGrafanaスタックへ直接送信します。

フロントエンドテレメトリ:3つのアプローチの比較

クライアント側のパフォーマンスやバグを追跡する際、エンジニアリングチームは通常、以下の3つのアプローチを検討します。

1. 商用SaaS APM(Datadog RUM、Sentry、New Relic)

SaaSベンダーは、導入の手間が少なく洗練されたダッシュボードを提供します。しかし難点は従量課金のコストが急増しやすいことです。月間500万セッション規模のトラフィックがある消費者向けWebアプリでは、APMの利用料金だけで毎月数千ドルに達することも珍しくありません。また、機密性の高いクライアントのテレメトリデータをサードパーティに預けることにもなります。

2. 自社製の独自ビーコンエンドポイント

GoやNode.jsで軽量なエンドポイントを構築し、navigator.sendBeacon()のペイロードを受け取ってElasticsearchやLokiに流し込むチームもあります。導入当初はうまく機能しますが、ユーザーセッションの解析、OpenTelemetryのトレースヘッダーのサポート、Core Web Vitalsの仕様変更への追従などが必要になると、運用保守の負担が急速に膨らみます。

3. Grafana FaroとOpenTelemetryの組み合わせ

Grafana Faroは、コンソールエラー、未ハンドルの例外、Web Vitals、ユーザージャーニーを計測できる、モジュール式の軽量Web SDK(gzip圧縮で約14KB)を提供します。Grafana AgentまたはAlloyを経由して、Faroはログ行を直接Lokiへ、分散トレースをTempoへとルーティングします。すべてのデータは自社のインフラ内に保持されます。

Grafana Faroをセルフホストするトレードオフ

メリット

  • 完全なデータ所有権: テレメトリデータがVPCの外部に出ることはありません。GDPRやHIPAA、社内の厳格なセキュリティポリシーへのコンプライアンス対応が容易になります。
  • 予測可能なコスト: セッション数に応じた従量課金に悩まされることはありません。費用はすでに管理しているディスクストレージとコンピュートリソースにのみ比例します。
  • 統合されたトレースコンテキスト: Faroは標準的なW3C traceparentヘッダーを伝播します。Reactのイベントハンドラーで検知したユーザーの低速なクリック操作から、バックエンドのPostgreSQLクエリまで一気通貫でトレース可能です。
  • Grafanaネイティブのワークフロー: 標準のダッシュボード変数やExploreクエリを使用して、Loki内のクライアントエラーログとTempo内の分散トレースの間をシームレスに行き来できます。

デメリット

  • パイプラインの管理負担: ストレージの保持期間設定、コレクターの稼働監視、データベースのディスクサイジングを自前で行う必要があります。
  • ソースマップの取り込み: 本番環境向けに圧縮・難読化されたバンドルのスタックトレースを復元するには、アクセス可能なソースマップエンドポイントをホストするか、ストレージバケットを手動で設定する必要があります。

アーキテクチャ概要

信頼性の高いセルフホスト環境では、Docker上で5つのコンポーネントを連携させます。

  • Grafana Faro Web SDK: ブラウザ上で動作し、エラー、ユーザーのクリック、Core Web Vitals(LCP、INP、CLS)を収集します。
  • Grafana Agent(またはGrafana Alloy): HTTPポートでリッスンし、FaroのJSONバッチを受信してメタデータをパースし、後続のシステムへデータを振り分けます。
  • Grafana Loki: フロントエンドのコンソールログ、ネットワークエラー、セッションイベントのインデックスを作成・保存します。
  • Grafana Tempo: フロントエンドのfetchやXHRリクエストによって生成されたOpenTelemetryトレーススパンを取り込みます。
  • Grafana Dashboard: RUMメトリクスを可視化し、トレースとログをワンクリックで相互に参照できるようにします。

フロントエンドでのクリックからバックエンドの低速クエリまでを関連付けることで、デバッグ時間を数時間から数分へと短縮できます。それでは実際にスタックを構築していきましょう。

Docker Composeを使用したデプロイ手順

ステップ1:Docker Composeスタックの作成

grafana-faro-stackというディレクトリを作成し、以下のdocker-compose.ymlファイルを追加します。

version: '3.8'

services:
  grafana:
    image: grafana/grafana:10.4.0
    container_name: faro-grafana
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - ./grafana/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
    depends_on:
      - loki
      - tempo

  loki:
    image: grafana/loki:2.9.4
    container_name: faro-loki
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/local-config.yaml

  tempo:
    image: grafana/tempo:2.3.1
    container_name: faro-tempo
    ports:
      - "3200:3200"
      - "4317:4317" # OTLP gRPC
      - "4318:4318" # OTLP HTTP
    command: [ "-config.file=/etc/tempo.yaml" ]
    volumes:
      - ./tempo/tempo.yaml:/etc/tempo.yaml

  grafana-agent:
    image: grafana/agent:v0.40.0
    container_name: faro-agent
    ports:
      - "12347:12347" # Faroレシーバー用ポート
      - "12345:12345" # Agent内部メトリクス用ポート
    volumes:
      - ./agent/agent-config.yaml:/etc/agent/agent-config.yaml
    command:
      - -config.file=/etc/agent/agent-config.yaml
      - -server.http.address=0.0.0.0:12345
    depends_on:
      - loki
      - tempo

ステップ2:Faroテレメトリ向けにGrafana Agentを設定

agent/agent-config.yamlを作成します。app_agent_receiverブロックでブラウザからのペイロードを受け付け、セッションメタデータを抽出して、ログをLokiへ、トレースをTempoへとルーティングします。

server:
  log_level: info

integrations:
  event_handler:
    enabled: true

app_agent_receiver:
  instance: production-web
  server:
    host: 0.0.0.0
    port: 12347
    cors_allowed_origins:
      - "*"
  sourcemaps:
    download: true
  logs:
    send_to: loki
  traces:
    send_to: tempo

logs:
  configs:
    - name: loki
      clients:
        - url: http://loki:3100/loki/api/v1/push

traces:
  configs:
    - name: tempo
      remote_write:
        - endpoint: tempo:4317
          insecure: true

ステップ3:Tempoストレージの設定

ローカルファイルストレージを設定したtempo/tempo.yamlを作成します。

server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

ingester:
  trace_idle_period: 10s
  max_block_bytes: 1_000_000
  max_block_duration: 5m

compactor:
  compaction:
    block_retention: 24h

storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo/traces
    wal:
      path: /tmp/tempo/wal

ステップ4:Grafanaデータソースのプロビジョニング

Grafanaの起動時にLokiとTempoが自動的に登録されるよう、grafana/datasources.yamlを作成します。

apiVersion: 1

datasources:
  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100
    isDefault: true

  - name: Tempo
    type: tempo
    access: proxy
    url: http://tempo:3200
    jsonData:
      httpMethod: GET
      tracesToLogs:
        datasourceUid: 'Loki'
        tags: ['job', 'instance', 'app']
        mappedTags:
          - key: 'service.name'
            value: 'app'

ステップ5:スタックの起動

すべてのコンテナをバックグラウンド(デタッチモード)で起動します。

docker compose up -d

すべてのコンテナが正常に動作していることを確認します。

docker compose ps

ステップ6:フロントエンドへのFaro Web SDKの組み込み

FaroのコアSDKとトレーシングパッケージをインストールします。

npm install @grafana/faro-web-sdk @grafana/faro-web-tracing

フロントエンドバンドルの可能な限り早い段階(src/index.tsxやsrc/main.jsなど)でFaroを初期化します。

import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';
import { TracingInstrumentation } from '@grafana/faro-web-tracing';

export const faro = initializeFaro({
  url: 'http://localhost:12347/collect',
  app: {
    name: 'storefront-ui',
    version: '2.1.4',
    environment: 'production',
  },
  instrumentations: [
    // コンソールエラー、キャッチされなかったPromise拒否、Web Vitalsを計測
    ...getWebInstrumentations({
      captureConsole: true,
      capturePerformance: true,
    }),
    // fetch/XHRリクエスト間でW3Cトレースヘッダーを伝播
    new TracingInstrumentation(),
  ],
});

// カスタムビジネスイベントを追跡
faro.api.pushEvent('checkout_completed', {
  cartTotal: '142.50',
  itemCount: '3',
  currency: 'USD',
});

ステップ7:データ受信の検証と確認

ブラウザのコンソールからテスト用のエラーを送信してパイプラインを検証します。

faro.api.pushError(new Error('StripeElementsError: カード要素の初期化に失敗しました'));

http://localhost:3000でGrafanaを開きます。Exploreに移動し、Lokiデータソースを選択して、次のLogQLクエリを実行します。

{app="storefront-ui"} |= "StripeElementsError"

ログレコードには、正確なブラウザのバージョン、OS、URLパス、ソースコードの行番号が表示されます。ユーザー全体のパフォーマンスを集計して監視するには、GrafanaダッシュボードID 17997をインポートしてください。実際のデバイス環境におけるLargest Contentful Paint(目標値: 2.5秒未満)、Interaction to Next Paint(INP、目標値: 200ms未満)、Cumulative Layout Shift(0.1未満)の本番環境での分布を即座に確認できます。

Share: