CrashlyticsのログをAI解析して Slackに週次レポートを送信する

目次

はじめに

サムザップ開発推進室ソリューションチーム所属の江畑と申します。

開発推進室は社内横断で開発効率向上を推進する部署であり、私の所属するソリューションチームは端的に言えば各プロジェクトの痒いところに手が届くようにサポートする取り組みをしています。

今回はソリューションチームにおける事例として、CrashlyticsのログをAI解析してSlackに週次レポートを送信する仕組みを作った件についてお話したいと思います。

各プロジェクトでCrashlyticsを導入しているものの、いまいち有効活用できていないというのが組織内で共通の課題だったため、その課題を解消するために本仕組みを構築しました。

※本記事で扱うログには、個人情報や入力内容などの機微情報は含まれておりません。また、解析は品質改善のみを目的としており、使用したデータがAIモデルの学習に利用されることもありません。

仕組みについて

仕組みについて

FirebaseとGoogle Cloud(以下GCP)のサービスを組み合わせて実現しています。

  1. CrashlyticsのログをBigQueryに日次エクスポート
  2. ログ抽出 + AI解析 + Slack通知を処理するCloud Functionを週次で実行

使用しているサービスは以下の通りです。

  • Crashlytics
  • BigQuery
  • Cloud Functions
  • Cloud Scheduler
  • Vertex AI

設計について

コストを抑えた設計にするため、Geminiと壁打ちしながら検討しました。

Geminiを選んだ理由はGoogle関連のプロダクトについて一番詳細を把握しているだろうと思ったためです。

実際のコストはまだ算出できるほど運用できていませんが、組織内での利用規模であれば低コストで運用できる見込みです。

参考として、BigQueryは月間1TBまでの無料枠があり、Cloud Functionsも月200万回の呼び出しまで無料です。

Vertex AIの推論コストが主な費用となりますが、flash-liteモデルは非常に安価に設定されています。

調べているうちにFirebase MCPサーバーがあることを知りましたが、今回はプロジェクトメンバーが能動的にアクションを起こさなくても情報を受け取れる仕組みを目指していたため見送りました。

実装について

利用プロジェクトのCI関連ツールを管理するリポジトリに tools/crash-analysis-reporter のような名前でCloud Functionsにデプロイする関数を作成しました。

Cloud Functions実装

ログ抽出

ログ抽出のクエリは以下の要件に基づいて書いています。

  • クラッシュ(Fatal)ログのみを対象とする
  • 直近7日間のログに絞り込む
  • issue_id単位で最新のイベントのみを取得(重複排除)
  • issue_idごとの発生回数と影響ユーザー数を集計する
  • iOS・Androidの両プラットフォームを統合して取得する
  • ユーザー操作履歴(最新50件)を取得する
  • 影響度(発生件数・影響ユーザー数)が大きい順にソートし、上限30件に制限する
const query = `
      WITH all_logs AS (
        SELECT * FROM \`${PROJECT_ID}.firebase_crashlytics.${BUNDLE_ID_IOS}\`
        UNION ALL
        SELECT * FROM \`${PROJECT_ID}.firebase_crashlytics.${BUNDLE_ID_ANDROID}\`
      ),
      filtered_logs AS (
        -- クラッシュ(Fatal)のみに絞り込み
        SELECT * FROM all_logs
        WHERE error_type = 'FATAL'
        AND event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
      ),
      issue_stats AS (
        SELECT
          issue_id,
          COUNT(*) as event_count,
          COUNT(DISTINCT installation_uuid) as user_count
        FROM filtered_logs
        GROUP BY issue_id
      ),
      latest_issues AS (
        SELECT
          event_timestamp, issue_id, issue_title, issue_subtitle, platform, bundle_identifier,
          operating_system.display_version as os_version,
          application.display_version as app_version,
          device.model as device_model,
          (
            SELECT ARRAY_TO_STRING(ARRAY(
              SELECT f.symbol FROM UNNEST(e.frames) AS f
            ), '\\n')
            FROM UNNEST(exceptions) AS e
            LIMIT 1
          ) as stack_trace,
          -- クラッシュ直前のユーザー操作ログ(最新50件)
          (
            SELECT ARRAY_TO_STRING(ARRAY(
              SELECT CONCAT(
                FORMAT_TIMESTAMP('%Y-%m-%d %H:%M:%S', l.timestamp, 'Asia/Tokyo'),
                ' | ',
                l.message
              )
              FROM UNNEST(IFNULL(logs, [])) AS l
              ORDER BY l.timestamp DESC
              LIMIT 50
            ), '\\n')
          ) as crash_logs
        FROM filtered_logs
        -- 重複排除
        QUALIFY ROW_NUMBER() OVER(PARTITION BY issue_id ORDER BY event_timestamp DESC, event_id DESC) = 1
      )
      SELECT li.*, st.event_count, st.user_count
      FROM latest_issues li
      JOIN issue_stats st ON li.issue_id = st.issue_id
      ORDER BY st.event_count DESC, st.user_count DESC
      LIMIT 30
    `;

AI解析

モデルは現時点でコストパフォーマンス的に優れている gemini-2.0-flash-lite を採用しました。

const model = vertex.getGenerativeModel({ model: 'gemini-2.0-flash-lite' });

プロンプトでは、クラッシュログのスタックトレースやissue情報に加えて、クラッシュ直前のユーザー操作ログも入力として渡し、以下の観点で解析を依頼しています。

  • プラットフォームごとの視点(iOS または Android)
  • クラッシュの原因と解決策の要約
  • ログの内容を参考にした、クラッシュに至るまでの処理の流れの推測

ユーザー操作ログを含めることで、スタックトレースだけでは分からない「どのような操作の流れでクラッシュに至ったか」をAIが推測できるようになります。

出力はマークダウン形式で返却されるよう指定しており、そのままSlackにアップロードできるようにしています。

Slack通知

解析ログが大量になることを考慮して、「週間クラッシュログ解析レポート(XX件)」というメッセージのスレッドに解析レポートをぶら下げるようにしました。

const parentMsg = await slack.chat.postMessage({
      channel: SLACK_CHANNEL,
      text: `*週間クラッシュ解析レポート (${rows.length}件)*`
    });

解析ログはSlackメッセージだと上限文字数やSlackフォーマットに沿わない文字が出力されることを考慮してマークダウンファイルとしてアップロードするようにしました。

initial_comment には issue タイトルに加えて、発生件数・影響ユーザー数・Crashlytics コンソールへのリンクを表示しています。

解析レポートは並列アップロードのためスレッド内の順序が保証されませんが、各投稿に影響度を表示することでどのクラッシュが重要かをひと目で判断できるようにしています。

await slack.files.uploadV2({
          channel_id: SLACK_CHANNEL,
          thread_ts: threadTs,
          filename: `crash_analysis_${crash.issue_id}.md`,
          content: mdContent,
          initial_comment: `*${crash.issue_title}*\n発生 ${crash.event_count}回 / 影響 ${crash.user_count}名(過去7日間)\n<${firebaseUrl}|Crashlytics で確認>`
        });

スレッドには以下のように投稿されます。

スレッドメッセージ

デプロイとスケジューリング

GitHub Actionsワークフロー

同様にリポジトリ配下にGitHub Actionsで実行するワークフローも実装しています。

ワークフローは関連ファイルのプッシュをトリガーにして以下の処理を行っています。

  1. Cloud Functionsへのデプロイ
  2. Cloud Schedulerのジョブ作成・更新

Cloud Schedulerのジョブ作成・更新ステップについては、gcloudコマンドでジョブが存在するか確認したうえで無ければ作成、存在したら更新という処理にしています。

存在確認の上で処理を分岐することで、冪等性を担保しています。

# ジョブが存在するか確認
if gcloud scheduler jobs describe $JOB_NAME --location=$LOCATION > /dev/null 2>&1; then
  echo "✅ 既存のジョブを更新します..."
  # ...
else
  echo "🚀 新規ジョブを作成します..."
  # ...
fi

実装後、gcloud CLIを使ってローカル環境でテストを行いました。テスト方法などについては割愛します。

まとめ

いままでGCPやFirebase関連はあまり触れる機会がなかったため、今回の実装を通じて苦手意識が薄らぎました。

今回の対応はコスト管理さえしっかりすれば他プロジェクトにも展開がしやすいので開発推進室ソリューションチームとしては模範的な対応ができたのかなと思っています。

仕組みとしてはまだ試験段階なので、運用経験を経てブラッシュアップを行いより良いツールにしていくつもりです。

今回の記事が同様の課題を抱えているプロジェクトにとって一助になれば幸いです。


江畑 真也

株式会社サイバーエージェント 2012年 中途入社
『真 戦国炎舞 -KIZNA-』『呪術廻戦 ファントムパレード』など開発に従事
開発推進室として社内横軸の業務効率化に携わる