「1回の訪問」はどこからどこまでか
Webサイトの行動ログを分析するとき、「1回のセッション(訪問)の中でユーザーは何ページ見たか」「カートに入れてから購買まで何分かかったか」を調べたい場面は多い。
しかし現実のログデータには、セッションIDが存在しないことがある。システムの設計上セッションを記録していない、古いデータでセッション情報が欠落している、複数のデータソースを統合した結果セッション情報が消えたなど理由はさまざまだ。
そのとき使うのが「30分ギャップルール」だ。
同一ユーザーのイベントログの中で、前のイベントから30分以上間隔が空いた場合、そこを「セッションの区切り」とみなす
これは Google Analytics をはじめ多くのアナリティクスツールが採用している業界標準的な定義だ。
今回はこのルールをSQLで実装し、行動ログに「セッションID」を割り当てるクエリを段階的に作る。GAP and ISLAND 問題と本質的に同じ発想で、応用の幅が非常に広いテクニックだ。
使用するテーブル
-- user_events(ユーザー行動ログ)
-- event_id : イベントID
-- user_id : ユーザーID
-- event_type : イベント種別('page_view', 'add_to_cart', 'purchase' など)
-- page_name : ページ名
-- occurred_at : イベント発生日時(TIMESTAMP型・JST)| event_id | user_id | event_type | page_name | occurred_at |
|---|---|---|---|---|
| E001 | U001 | page_view | トップ | 2024-12-01 10:00:00 |
| E002 | U001 | page_view | シャンプー商品ページ | 2024-12-01 10:03:00 |
| E003 | U001 | add_to_cart | シャンプー商品ページ | 2024-12-01 10:05:00 |
| E004 | U001 | page_view | トップ | 2024-12-01 14:00:00 |
| E005 | U001 | page_view | コンディショナー商品ページ | 2024-12-01 14:04:00 |
| E006 | U001 | purchase | 購入完了 | 2024-12-01 14:12:00 |
| E007 | U002 | page_view | トップ | 2024-12-01 11:00:00 |
U001 の 10:05 から 14:00 の間は168分空いている。30分ルールでは E003 と E004 の間がセッションの区切りだ。
STEP 1 ― 前のイベントからの経過時間を計算する
まず各イベントに「同じユーザーの直前のイベントからの経過分数」を付与する。LAG を使う。
-- STEP1: 前イベントからの経過時間を計算する
WITH event_with_prev AS (
SELECT
event_id,
user_id,
event_type,
page_name,
occurred_at,
-- 同じユーザーの1つ前のイベント発生時刻
LAG(occurred_at) OVER (
PARTITION BY user_id
ORDER BY occurred_at ASC
) AS prev_occurred_at
FROM user_events
),
with_gap AS (
SELECT
event_id,
user_id,
event_type,
page_name,
occurred_at,
prev_occurred_at,
-- 前イベントからの経過分数(初回イベントは NULL)
DATE_DIFF('minute', prev_occurred_at, occurred_at) AS minutes_since_prev
FROM event_with_prev
)
SELECT *
FROM with_gap
ORDER BY user_id, occurred_at;出力イメージ(U001)
| event_id | user_id | event_type | occurred_at | prev_occurred_at | minutes_since_prev |
|---|---|---|---|---|---|
| E001 | U001 | page_view | 10:00:00 | NULL | NULL |
| E002 | U001 | page_view | 10:03:00 | 10:00:00 | 3 |
| E003 | U001 | add_to_cart | 10:05:00 | 10:03:00 | 2 |
| E004 | U001 | page_view | 14:00:00 | 10:05:00 | 235 |
| E005 | U001 | page_view | 14:04:00 | 14:00:00 | 4 |
| E006 | U001 | purchase | 14:12:00 | 14:04:00 | 8 |
E004 の前イベントから235分空いており、明らかに「セッションの区切り」だと分かる。
STEP 2 ― セッション開始フラグを立てる
「前のイベントから30分以上空いた行」または「そのユーザーの最初のイベント」を「セッション開始」としてフラグを立てる。
-- STEP2: セッション開始フラグを付与する
WITH event_with_prev AS (
SELECT
event_id, user_id, event_type, page_name, occurred_at,
LAG(occurred_at) OVER (
PARTITION BY user_id
ORDER BY occurred_at ASC
) AS prev_occurred_at
FROM user_events
),
with_gap AS (
SELECT
*,
DATE_DIFF('minute', prev_occurred_at, occurred_at) AS minutes_since_prev
FROM event_with_prev
),
with_session_start AS (
SELECT
*,
-- セッション開始フラグ
-- 条件1: ユーザーの最初のイベント(prev が NULL)
-- 条件2: 前イベントから30分以上空いた
CASE
WHEN prev_occurred_at IS NULL THEN 1 -- 最初のイベント
WHEN minutes_since_prev >= 30 THEN 1 -- 30分ルール
ELSE 0
END AS is_session_start
FROM with_gap
)
SELECT *
FROM with_session_start
ORDER BY user_id, occurred_at;
出力イメージ(U001)
| event_id | user_id | occurred_at | minutes_since_prev | is_session_start |
|---|---|---|---|---|
| E001 | U001 | 10:00:00 | NULL | 1 |
| E002 | U001 | 10:03:00 | 3 | 0 |
| E003 | U001 | 10:05:00 | 2 | 0 |
| E004 | U001 | 14:00:00 | 235 | 1 |
| E005 | U001 | 14:04:00 | 4 | 0 |
| E006 | U001 | 14:12:00 | 8 | 0 |
E001 と E004 が「セッション開始」とマークされた。
STEP 3 ― セッション番号を割り当てる(核心ロジック)
ここが今回の核心だ。
is_session_start の累積和(SUM() OVER (ORDER BY ...))を使う。
セッション開始フラグを上から順に足し込んでいくと、セッションが変わるたびに累積和が1増える。同じセッション内のイベントはすべて同じ累積和になる。これをセッション番号として使う。
E001: is_session_start=1 → 累積和=1(セッション1)
E002: is_session_start=0 → 累積和=1(セッション1)
E003: is_session_start=0 → 累積和=1(セッション1)
E004: is_session_start=1 → 累積和=2(セッション2)
E005: is_session_start=0 → 累積和=2(セッション2)
E006: is_session_start=0 → 累積和=2(セッション2)-- STEP3: セッション番号を割り当てる
WITH event_with_prev AS (
SELECT
event_id, user_id, event_type, page_name, occurred_at,
LAG(occurred_at) OVER (
PARTITION BY user_id
ORDER BY occurred_at ASC
) AS prev_occurred_at
FROM user_events
),
with_gap AS (
SELECT *,
DATE_DIFF('minute', prev_occurred_at, occurred_at) AS minutes_since_prev
FROM event_with_prev
),
with_session_start AS (
SELECT *,
CASE
WHEN prev_occurred_at IS NULL THEN 1
WHEN minutes_since_prev >= 30 THEN 1
ELSE 0
END AS is_session_start
FROM with_gap
),
with_session_num AS (
SELECT
event_id,
user_id,
event_type,
page_name,
occurred_at,
minutes_since_prev,
is_session_start,
-- セッション番号 = ユーザー内でのセッション開始フラグの累積和
SUM(is_session_start) OVER (
PARTITION BY user_id
ORDER BY occurred_at ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_num
FROM with_session_start
)
SELECT *
FROM with_session_num
ORDER BY user_id, occurred_at;
出力イメージ(U001)
| event_id | user_id | event_type | occurred_at | is_session_start | session_num |
|---|---|---|---|---|---|
| E001 | U001 | page_view | 10:00:00 | 1 | 1 |
| E002 | U001 | page_view | 10:03:00 | 0 | 1 |
| E003 | U001 | add_to_cart | 10:05:00 | 0 | 1 |
| E004 | U001 | page_view | 14:00:00 | 1 | 2 |
| E005 | U001 | page_view | 14:04:00 | 0 | 2 |
| E006 | U001 | purchase | 14:12:00 | 0 | 2 |
| E007 | U002 | page_view | 11:00:00 | 1 | 1 |
セッション番号が割り当てられた。
STEP 4 ― セッションIDを生成して完成させる(完成版)
user_id + session_num の組み合わせを「セッションID」として使いやすい文字列に変換する。
-- STEP4: セッションID付きのログテーブル(完成版)
WITH event_with_prev AS (
SELECT
event_id, user_id, event_type, page_name, occurred_at,
LAG(occurred_at) OVER (
PARTITION BY user_id
ORDER BY occurred_at ASC
) AS prev_occurred_at
FROM user_events
),
with_gap AS (
SELECT *,
DATE_DIFF('minute', prev_occurred_at, occurred_at) AS minutes_since_prev
FROM event_with_prev
),
with_session_start AS (
SELECT *,
CASE
WHEN prev_occurred_at IS NULL THEN 1
WHEN minutes_since_prev >= 30 THEN 1
ELSE 0
END AS is_session_start
FROM with_gap
),
with_session_num AS (
SELECT
event_id, user_id, event_type, page_name, occurred_at,
SUM(is_session_start) OVER (
PARTITION BY user_id
ORDER BY occurred_at ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_num
FROM with_session_start
)
SELECT
event_id,
user_id,
event_type,
page_name,
occurred_at,
session_num,
-- セッションIDを文字列として生成(user_id + '_' + session_num)
user_id || '_' || CAST(session_num AS VARCHAR) AS session_id
FROM with_session_num
ORDER BY user_id, occurred_at;STEP 5 ― セッション単位のサマリを出す
セッションIDが付いたので、セッション単位の集計ができる。
-- STEP5: セッション単位の行動サマリ
WITH event_with_prev AS (
SELECT
event_id, user_id, event_type, page_name, occurred_at,
LAG(occurred_at) OVER (
PARTITION BY user_id ORDER BY occurred_at ASC
) AS prev_occurred_at
FROM user_events
),
with_gap AS (
SELECT *,
DATE_DIFF('minute', prev_occurred_at, occurred_at) AS minutes_since_prev
FROM event_with_prev
),
with_session_num AS (
SELECT
event_id, user_id, event_type, page_name, occurred_at,
SUM(CASE
WHEN prev_occurred_at IS NULL THEN 1
WHEN minutes_since_prev >= 30 THEN 1
ELSE 0
END) OVER (
PARTITION BY user_id
ORDER BY occurred_at ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_num
FROM with_gap
)
SELECT
user_id,
session_num,
user_id || '_' || CAST(session_num AS VARCHAR) AS session_id,
-- セッション開始・終了日時
MIN(occurred_at) AS session_start,
MAX(occurred_at) AS session_end,
-- セッション継続時間(分)
DATE_DIFF('minute', MIN(occurred_at), MAX(occurred_at))
AS session_duration_min,
-- セッション内のイベント数(=ページビュー数など)
COUNT(event_id) AS event_count,
-- カートへの追加があったか
MAX(CASE WHEN event_type = 'add_to_cart' THEN 1 ELSE 0 END)
AS has_add_to_cart,
-- 購買があったか
MAX(CASE WHEN event_type = 'purchase' THEN 1 ELSE 0 END)
AS has_purchase,
-- 閲覧したページ一覧(カンマ区切り)
ARRAY_JOIN(
ARRAY_AGG(page_name ORDER BY occurred_at ASC),
' → '
) AS page_path
FROM with_session_num
GROUP BY user_id, session_num
ORDER BY user_id, session_num;
完成した出力イメージ
| session_id | session_start | session_end | duration_min | event_count | has_add_to_cart | has_purchase | page_path |
|---|---|---|---|---|---|---|---|
| U001_1 | 10:00:00 | 10:05:00 | 5 | 3 | 1 | 0 | トップ → シャンプー商品ページ → シャンプー商品ページ |
| U001_2 | 14:00:00 | 14:12:00 | 12 | 3 | 0 | 1 | トップ → コンディショナー商品ページ → 購入完了 |
| U002_1 | 11:00:00 | 11:00:00 | 0 | 1 | 0 | 0 | トップ |
U001 のセッション1はカートに追加したが購買せずに離脱(カゴ落ち)。セッション2で戻ってきて別商品を購買している。このように「1ユーザーの複数セッションをまたいだ行動」が追跡できる。
応用:カゴ落ち分析
セッションサマリがあれば、「カートに入れたが購買しなかったセッション(カゴ落ち)」の抽出も一行だ。
-- カゴ落ちセッションの抽出
SELECT
user_id,
session_id,
session_start,
session_duration_min,
event_count,
page_path
FROM session_summary -- 上記のSTEP5のクエリをCTEまたはビューとして使う
WHERE has_add_to_cart = 1
AND has_purchase = 0
ORDER BY session_start DESC;このリストに対してカゴ落ちメール(「忘れ物はありませんか?」系のリマインドメール)の配信対象を作ることができる。
実務での運用ヒント
① 30分という閾値は調整できる
通販ECでは30分が一般的だが、熟慮型の高単価商品(家電・家具)では「60分」を使うこともある。コンテンツサイトやアプリでは「15分」と短く設定する場合もある。自社データで「実際にどのくらいの間隔が多いか」を確認してから決めるのが正確だ。STEP1 の minutes_since_prev の分布をヒストグラムで見れば、自然な区切りが見えてくる。
② 同一秒に複数イベントが発生する場合
イベントが同一秒に複数記録されるケースがある(ページ遷移と同時にトラッキングピクセルが発火するなど)。ORDER BY occurred_at ASC だけでは順序が不定になるため、ORDER BY occurred_at ASC, event_id ASC のようにタイブレーカーを加えると安定する。
③ データ量が大きい場合の分割実行
行動ログは注文ログより桁違いに行数が多い。数億行のログに対して全件 LAG を計算するとクエリが重くなる。Treasure Data の場合は WHERE occurred_at >= DATE_ADD('day', -30, CURRENT_DATE) で期間を絞るか、月次パーティションに分けて実行する設計が現実的だ。
まとめ ― ストリーク分析との共通点
今回のセッション再構築とストリーク分析は、本質的に同じパターンで解ける。
| ストリーク分析 | セッション再構築 | |
|---|---|---|
| 区切りの定義 | 連続しない月(購買のない月) | 30分以上の間隔 |
| グループキーの作り方 | 月番号 − ROW_NUMBER | is_session_start の累積 SUM |
| 結果 | 連続購買区間(Island) | セッション |
どちらも「連続しているグループを見つけて番号を振る」という GAP and ISLAND の考え方だ。区切りの定義だけが異なる。このパターンを一度習得すると、「週次ログイン連続記録」「連続レビュー投稿」「一定間隔のクリック列」など、さまざまな「連続・区切り」分析に使い回せる。