
この記事は STORES Advent Calendar 2025 の 8 日目の記事です。
こんにちは、 STORES でレジアプリのモバイルオーダー周りの開発をしている yu です。
レジアプリでは、 Firebase Analytics を用いて以下のような粒度で行動ログを取得しています。
- 画面Aを開いた
- トグルをOFFにした
- ボタンBをタップした
- 画面Cが開いた
しかし、アプリの行動ログはそのままだと「点」の集まりにしか見えません。様々な事業者さまの、様々な端末の行動ログが、テーブルの行としてバラバラに並んでいるだけです。
これは例えば、家族全員分の1週間分のレシートを机にバラまいている状態とよく似ています。1枚1枚は情報を持っているのに、1人ずつ時系列に並べてみないと「どんな1週間だったか」は見えてきませんよね。
この記事では、バラバラな行動ログを時系列を持った情報として扱い、意図しない挙動を検知する取り組みについて説明します。
背景
必要性が高まったのは、iPadOS 16 のサポートを外すタイミングで、画面遷移周りを大きくリファクタリングしたときでした。 NavigationView が非推奨 (Deprecated) となったため、段階的に NavigationStack へ置き換えるという作業をしていました。
しかし、画面遷移周りは不具合が発生したとしても、クラッシュログが上がってくる訳でもなく、バックエンドでエラーが出ることもありません。
特に、今回の不具合検出の対象とするシナリオは「お会計へ進むボタン押下後に、お支払い方法選択画面が正常に開くこと」というものです。使えなくなると最も影響がありますがエラーは出ません。
| お会計へ進むボタン | お支払い方法選択画面 |
|---|---|
![]() |
![]() |
アプリリリース前の QA やリグレッションテストを通して不具合を検出できれば最善ですが、万が一見逃されてしまってリリースされてしまったとしても最速で気づけるような仕組みが必要です。
方針
レジアプリで収集された行動ログは Firebase Analytics で収集され、最終的に BigQuery に蓄積されていきます。 BigQuery にログが入っていれば SQL でデータ整形が簡単にできるので楽ですね。
では、どのようにして先述のシナリオのような静かな不具合を検知するかどうか考えます。
単純に考えると、「お会計へ進むボタンが押下された」ログの次に「お支払い方法選択画面が表示された」というログが入っていれば良さそうですが、裏側では色々なログが流れてきているためそう単純にはいきません。

最終的に、 「お会計へ進むボタンが押下された」という 2 つのログの間に、「お支払い方法選択画面が表示された」というログが入っているかどうか を検知することとしました。

つまり、一度「お会計へ進むボタン」が押下され、次に押下されるまでに「お支払い方法選択画面」が表示されていれば問題無し、そうでなければ異常ということです。
気をつけること
BigQuery で SQL を使って簡単にログを整理できるのは良いのですが、 BigQuery と聞いて真っ先に思い浮かんだのは、請求の話題でした。「なんかやっちまったら請求がえぐいことになる」という漠然としたイメージがあったので、ついでに調べてみました。
BigQuery の料金モデルには「オンデマンドコンピューティング料金」と「容量コンピューティング料金」の 2 種類があります。
特に「オンデマンドコンピューティング料金」モデルの場合、公式の料金詳細を見ると「選択した列にある処理される全データに応じて課金され、列ごとの全データは、列のデータの種類に基づいて計算される」との記載があり、適当に全カラムを選択 (SELECT *) してしまうととんでもない請求が来てしまう可能性もあるため注意してください。
また「容量コンピューティング料金」モデルの場合であっても、日時を指定するなどスキャン量をできるだけ減らさないとクエリ処理容量が増加して、これまた莫大な請求が来てしまうため注意してください。
SQL クエリの構築
お金の面は一旦安心できたところで、どのようにして異常検知のクエリを実装していくか考えます。
クエリ全体は、次の3つのステップで構成することにしました。
app_events- タップ系イベントと画面表示系イベントをひとつのタイムラインに統合
target_button_events- 対象ボタン(お会計へ進むボタン:
checkout_button)の1回目の押下イベントと、その次の押下イベントを取得
- 対象ボタン(お会計へ進むボタン:
events_in_range- ボタン押下から次の押下までの間に、想定している後続イベント (お支払い方法選択画面:
payment_method_screen) をカウント - 発生数
event_count = 0のものだけ抽出
- ボタン押下から次の押下までの間に、想定している後続イベント (お支払い方法選択画面:
1. 必要な行動ログを集約
まずは、タップと画面表示のテーブルが分かれているため、ユーザーの行動ログを集約します。
WITH `app_events` AS ( SELECT `device_id`, `event_timestamp`, `event_name`, `app_version` FROM `tap_events` UNION DISTINCT SELECT `device_id`, `event_timestamp`, `event_name`, `app_version` FROM `screen_events` ),
こうすることで、机にバラまかれていたレシートを、家族1人ずつの束にまとめることができました。
今回は何度もテーブルを結合させたり、条件が多かったりするので、 WITH を用いて共通テーブルを作っています。
2. デバイス毎に特定のイベントを時系列順に並べ変え
ここでは「checkout_button が押されてから、次に checkout_button が押されるまで」を1区間とします。
`target_button_events` AS ( SELECT `stores`.`store_name`, `app_events`.`device_id`, `app_events`.`app_version`, `app_events`.`event_timestamp` AS `button_time`, LEAD(`app_events`.`event_timestamp`) OVER( PARTITION BY `app_events`.`device_id` ORDER BY `app_events`.`event_timestamp` ) AS `next_button_time` FROM `app_events` WHERE `event_name` = 'checkout_button' ),
ここでのポイントは LEAD というウィンドウ関数です。 LEAD 関数は、ある行に対して、後ろの行の値を取得できます。
上のコードでは、特定のデバイスの「支払い方法選択画面に進むボタン」が押されたログのみ取得をしているので、 LEAD 関数を用いることで、次のボタン押下の時刻を取得できます。
3. 区間内に特定のイベントがあるかチェック
残すは、さきほど取得した2回の押下の間に、特定のイベントが存在しているかどうかを確認するだけです。
`events_in_range` AS ( SELECT `target_button_events`.`store_name`, `target_button_events`.`device_id`, `target_button_events`.`app_version`, `target_button_events`.`button_time`, `target_button_events`.`next_button_time`, COUNT(`app_events`.`event_timestamp`) AS `event_count` FROM `target_button_events` LEFT JOIN `app_events` ON `target_button_events`.`device_id` = `app_events`.`device_id` AND `app_events`.`event_name` IN ( 'payment_method_screen', ... -- 他にも除外したいイベントがあれば追加 ) AND `app_events`.`event_timestamp` > `target_button_events`.`button_time` AND ( `target_button_events`.`next_button_time` IS NULL OR `app_events`.`event_timestamp` < `target_button_events`.`next_button_time` ) GROUP BY 1,2,3,4,5,6 )
やや LEFT JOIN 辺りの条件が複雑ですが、やっていることは以下のとおりでシンプルです。
- 特定のデバイスのイベント (
app_events) を結合 - 特定のイベントがあるかどうかをチェック (今回は「支払い方法選択画面:
payment_method_screen」) - 1回目のボタン押下の後のイベントのみを抽出
- 2回目のボタン押下が無い場合 or 2回目のボタン押下より前のイベントを抽出
そして COUNT で、ボタン押下の間のイベント数をカウントしてあげています。
4. 結果表示
最後に作った events_in_range テーブルから、ボタン押下の間のイベント数 (event_count) が 0 のものだけ抽出して表示します。
SELECT `store_name`, `device_id`, `app_version`, TIMESTAMP_MICROS(`button_time`) AS `button_time`, TIMESTAMP_MICROS(`next_button_time`) AS `next_button_time` FROM `events_in_range` WHERE `event_count` = 0 ORDER BY `button_time` DESC;
これのクエリを実行して検索結果が無ければ安心ですね。
実際に実行してみたところ、検索結果は無く一安心です。
今後の展望
今回のクエリは、特定のボタン名やイベント名をハードコードしています。
このまま SQL クエリを実行するだけだと、お会計フロー専用になってしまうため、実運用には課題が残ります。
改善ポイント
- 定期実行したい
- 異常があれば自動で通知してほしい
- 1つのクエリで色々なフローの異常を検知したい
と思っていたのですが、定期実行と通知に関しては、 BigQuery の Scheduled queries という機能で実現できそうです。
3つ目に関しては、異常検知の対象のフローを定義したテーブルを用意しておくと、シンプルで良さそうと思っています。
ただ、データの運用については自分の知識が全く無いため、データチームの方とよく相談して決めていきたいです。
おわりに
行動ログは1行1行で見ていくとあまり意味は生まれないかもしれませんが、データの使い方で自由に意味を加えることができます。
最近は AI が SQL を書いてくれて、もう人が書くようなものではないかもしれませんが、このような品質向上のアイデアがあるんだと少しでも思っていただければ嬉しいです。
個人的にも、普段何気無く書いている行動ログを品質向上に活用していく第一歩として、良い機会になりました。
また STORES ではエンジニアを絶賛募集中です。 ぜひ採用サイトにも遊びに来てください。

