rrweb でブラウザ操作を記録・再生する
はじめに
rrweb は Web アプリケーション上のユーザ操作をブラウザレベルで記録・再現できる OSS ライブラリです。 一般にデバッグや不具合追跡の際に、スクリーンショットや画面録画を用いることがありますが、これらから DOM の状態や入力値の詳細を正確に把握するのは困難です。
rrweb はページ全体の DOM をスナップショットとして記録し、以降の変化は差分イベントとして追記します。 クリックや入力操作、ページ遷移といったユーザ操作から DOM の変化まで、ブラウザイベントを構造化されたフォーマット(JSON)で丸ごと保存・取得できます。
この仕組みはモニタリング・分析プロダクトのブラウザ操作記録機能のバックエンドとして広く採用されており、有名どころでは Datadog RUM、LogRocket、PostHog、Sentry の Session Replay でバグ再現やユーザ行動の分析に活用されています。
今回のブログでは、rrweb の概要や取得できるイベント、内部構造について整理した上で、実際に記録したデータからユーザ操作を抽出するための実装例を紹介したいと思います。
rrweb とは
rrweb の "rr" とは「record(記録)」と「replay(再生)」の頭文字をとったもので record-replay web の略です。 記録の開始時点でページ全体の DOM ツリーをスナップショットとして取得し、以降の変化はインクリメンタルな差分イベントとして追記します。 rrweb は記録データのサイズを最小限に抑えながら任意の時点のページ状態を再構築できる設計になっています。
DOM ツリー:
Web ページを構成する HTML の要素をツリー構造で表したもの。 HTML はタグが入れ子になっているが、ブラウザはそれを
documentをルートとするツリー構造として内部に保持する。 各要素がノードと呼ばれる単位になり、親子・兄弟の関係で繋がっている。公式ドキュメント:Document Object Model (DOM) | MDN
rrweb の全体像を図示すると以下のようになります。
rrweb は記録開始時にまず FullSnapshot と Meta を発行し、以降はユーザ操作や DOM の変化に応じて IncrementalSnapshot を追記していきます。
Record:記録
rrweb は @rrweb/record パッケージをページに組み込み rrweb.record() を呼び出すことで利用できます。
記録されたイベントはコールバック関数を通じて受け取り、サーバに送信して保存します。
Replay:再生
@rrweb/replay パッケージの Replayer に記録したイベント配列を渡し、play() を呼び出すと iframe 上でセッションを再現できます。
イベントの種類と構造
rrweb のイベントはすべて以下の共通フィールドを持ちます。
| フィールド | 概要 |
|---|---|
| type | イベント種別 |
| timestamp | ミリ秒精度の UNIX タイムスタンプ |
| data | 詳細データ(操作ログ) |
イベントタイプ一覧
rrweb が定義するイベントタイプは全部で 8 種類あります。
以下の表は記録セッション中の出現頻度・重要度の高い順に並べています。
| type | 名称 | 概要 | 発行タイミング |
|---|---|---|---|
| 2 | FullSnapshot | DOM ツリー全体のスナップショット | 記録開始時に 1 回 |
| 3 | IncrementalSnapshot | DOM の差分変化またはユーザ操作 | 操作・DOM 変化のたびに繰り返し |
| 4 | Meta | URL・viewport サイズ | 記録開始時・ページ遷移時 |
| 0 | DomContentLoaded | ページロードイベント | DOMContentLoaded 発火時 |
| 1 | Load | ページロード完了 | load イベント発火時 |
| 5 | Custom | アプリケーション定義のカスタムイベント | addCustomEvent() を呼び出した場合のみ |
| 6 | Plugin | プラグイン定義のイベント | プラグインを組み込んだ場合のみ |
| 7 | Asset | 画像・フォント等の外部アセット | プラグインを組み込んだ場合のみ |
中でも FullSnapshot / IncrementalSnapshot / Meta の 3 種類はすべての記録セッションに必ず含まれ、rrweb の仕組みの核心をなします。
DomContentLoaded / Load はページロードの区切りを示す補助的なイベント、Custom / Plugin / Asset はカスタムイベントやプラグインを組み込んだ場合にのみ出現するため、通常のセッションデータを解析する上で意識する必要はありません。
以降で、前者の 3 種類について詳しく見ていきます。
FullSnapshot(type=2)
FullSnapshot は記録開始時に一度だけ発行されます。 ページ全体の DOM ツリーを再帰的な JSON で表現し、各ノードには一意の数値 ID を割り当てます。
FullSnapshot の構造
上記の JSON が表すノードの親子関係をツリーで示すと以下のようになります。
childNodes による入れ子が親子関係に対応し、各ノードには一意の数値 ID が振られます。
ノードタイプは type フィールドで区別します。
FullSnapshot は、前述の EventType(0〜7)とは独立した enum でノード自体の種別を示します。
| type | 名称 | 対応する DOM |
|---|---|---|
| 0 | Document | document オブジェクト |
| 1 | DocumentType | <!DOCTYPE html> 宣言 |
| 2 | Element | <div> / <button> 等のタグ |
| 3 | Text | タグ間のテキストノード |
| 4 | CDATA | CDATA セクション(HTML では稀) |
| 5 | Comment | <!-- コメント --> |
Web 標準(W3C / WHATWG が定める DOM 仕様) の Node.nodeType は ELEMENT_NODE=1・TEXT_NODE=3・DOCUMENT_NODE=9 のように 1 始まりの不連続な数値ですが、rrweb のノードタイプは 0 始まりの連番で独自に定義されています。
種別名は Web 標準に対応していますが、数値は異なります。
DOM を JSON にシリアライズする際の設計(例:スクリプトタグの無効化・相対パスの絶対化・一意 ID の付与)は こちらのドキュメント が参考になります。
Meta(type=4)
Meta はページ遷移時やセッション開始直後に発行され、現在の URL と viewport サイズを記録します。
Meta の構造
IncrementalSnapshot(type=3)
IncrementalSnapshot は DOM の変化とユーザ操作の両方を表す最も頻繁に発行されるイベントです。
以下の source フィールドがサブタイプを示します。
| source | 名称 | 概要 |
|---|---|---|
| 0 | Mutation | DOM ノードの追加・削除・属性変更 |
| 1 | MouseMove | マウスの移動軌跡 |
| 2 | MouseInteraction | クリック・フォーカス・ホバー等のマウス操作 |
| 3 | Scroll | スクロール位置の変化 |
| 4 | ViewportResize | viewport サイズの変化 |
| 5 | Input | テキスト入力・チェックボックス操作 |
| 6 | TouchMove | タッチ操作 |
| 7 | MediaInteraction | 動画・音声の操作 |
| 8 | StyleSheetRule | CSS ルールの変化 |
| 9 | CanvasMutation | Canvas への描画操作 |
| 10 | Font | フォントの読み込み |
| 11 | Log | コンソールログの記録 |
| 12 | Drag | ドラッグ操作 |
| 13 | StyleDeclaration | インラインスタイルの変化 |
| 14 | Selection | テキスト選択の変化 |
| 15 | AdoptedStyleSheet | Constructable Stylesheets の変化 |
| 16 | CustomElement | カスタム要素の変化 |
これらのうち、クリック・入力・ページ遷移といったユーザの意図的な操作を直接表すのは MouseInteraction(source=2)と Input(source=5)の 2 つです。 MouseMove・Scroll・TouchMove・Drag・Selection 等は操作に付随する軌跡や副作用、StyleSheetRule / Font / CanvasMutation 等は DOM の外側の変化を表します。
また、Mutation(source=0)は SPA において操作に伴うダイアログ・メニュー等の DOM 追加・削除を記録するため、特定の Web サイトでの操作の文脈を正確に把握する上で欠かせないイベントです。
以降では Mutation / MouseInteraction / Input の 3 種類について詳しく見ていきます。
Mutation(source=0)
Mutation は DOM への動的なノード追加・削除を記録します。 ダイアログやドロップダウンのように、ユーザ操作に応じて DOM が動的に変化する際に発行されます。
Mutation の構造
MouseInteraction(source=2)
MouseInteraction はマウスのクリック・フォーカス・ホバー等を記録し、type フィールドが操作の種別を示します。
| type | 操作 |
|---|---|
| 0 | MouseUp |
| 1 | MouseDown |
| 2 | Click |
| 3 | ContextMenu |
| 4 | DblClick |
| 5 | Focus |
| 6 | Blur |
| 7 | TouchStart |
| 8 | TouchMove_Departed |
| 9 | TouchEnd |
| 10 | TouchCancel |
MouseInteraction の構造
id フィールドが操作対象のノード ID です。
Input(source=5)
Input はテキストフィールドへの入力やチェックボックスの状態変化を記録します。
Input の構造
text には入力完了後の最終的な値が入ります。
Input イベントは キーストロークのたびに発行され、text はその時点の入力値に都度更新 されていきます。
DOM 追跡の仕組み
rrweb は内部で Mirror と呼ばれる「id → ノード情報」のマッピング構造を持ちます。
Mirror は FullSnapshot で各ノードに割り当てた一意な ID をキーとして、ノードの詳細情報(例:タグ名・属性・テキスト・親の ID)を保持し、後続の IncrementalSnapshot がノード ID だけで対象要素を参照できるようにする仕組みです。
Mirror の役割
rrweb では各ノードを serializedNodeWithId 型で表現しており、id フィールドが一意の識別子となります。
IncrementalSnapshot の MouseInteraction や Input には操作対象のノード ID しか含まれていません。
例えば、「ID=42 のノードがクリックされた」というデータだけでは、そのノードが何であるか(タグ名・属性・テキストなど)がわかりません。 ノードの詳細情報は FullSnapshot に含まれているため、先に FullSnapshot を走査して「id → ノード情報」のマッピングを構築しておき、後続のイベントが来たときにそのマッピングを参照する必要があります。
Mirror はこのマッピングをイベント列全体を通じて管理し、任意のノード ID から対象要素の詳細情報を参照できる仕組みを提供します。
ユーザアクションの抽出
rrweb の記録データには、DOM の変化やユーザ操作がそのままイベントとして記録されています。 では、この膨大なイベント列からユーザが「何をしたか」を意味ある単位で取り出すにはどうすればよいでしょうか。
このセクションでは、イベント列から操作単位(アクション)を抽出する方法を見ていきます。
CSS セレクタの活用
Mirror を参照することで、クリックや入力の対象ノードのタグ名・属性・テキストを把握できますが、ノード ID は記録のたびに採番される一時的な番号です。 同じボタンでもセッションが変われば異なる ID が割り当てられるため、ID だけでは「どの要素を操作したか」を他の場面で再現することができません。
そこで、Mirror のノード情報から CSS セレクタを生成する 方法を取ります。
CSS セレクタは id や class、data-* 属性等ノードの属性情報から構成されており、Mirror が保持するノード情報と直接対応しています。
document.querySelector() に代表される Web 標準の要素特定手段であるため、DOM 構造が同じであれば記録時とは異なるコンテキストでも同じ要素を指定できます。
各属性にはページの変更に対する安定性の差があります。 id は HTML 仕様で一意性が保証されている一方、class はリファクタリングで変わりやすく再現性が下がります。 そのため、安定性の高い属性から順に評価し、最初に一致したものをセレクタとして採用します。
優先度はページの変更に対する安定性の高い順にします。
id 属性
HTML の仕様でページ内に一つしか存在できないことが保証されているため、最も確実に同じ要素を指定できます。
- 生成例:
#submit-btn、#main-content
data-testid
デザイン変更や DOM 構造の変更があっても、明示的に付与した属性は残りやすいため、id の次に安定しています。
- 生成例:
[data-testid="login-form"]、[data-testid="search-input"]
name 属性
フォーム送信時のキーとして使われるため、フォーム要素では意味的に安定しています。
name 属性は、id や data-testid がない入力フィールドの代替として機能します。
- 生成例:
input[name="email"]、select[name="country"]
class 属性
デザインやリファクタリングで変わりやすいため再現性は下がります。 複数の値を持つ場合は先頭のクラス名のみを使用します。
- 生成例:
button.btn-primary、div.modal
nth-of-type
上記のいずれも持たない要素への最終手段です。 DOM ツリー上の位置を使うため、要素の追加・削除があると位置がずれて再現できなくなります。
Mirror が保持する親ノードの ChildNodes リストを先頭からスキャンし、対象ノードの位置に達するまでに同タグ名のノードが何個あるかを数えることで算出します。
- 生成例:
button:nth-of-type(2)、li:nth-of-type(5)
こうして生成されたセレクタは、セッションの再生だけでなく、記録データの分析にも活用できます。 各イベントにセレクタを付与することで、特定の要素が操作されたセッションの絞り込みや、ページ内のクリック分布の可視化が可能になります。
実例では Datadog RUM の Heatmaps や LogRocket の Clickmaps では、クリック対象の CSS セレクタを使って特定の要素が操作されたセッションを絞り込む機能が提供されています。
rrweb ログからユーザアクションを取得する
ここまでの説明を踏まえて、実際に rrweb の記録データ(JSON ログ)からアクション一覧を取得する処理を Go で実装してみます。
サンプルコードは こちら にあります。
イベントの受け取り方
rrweb のイベントは type によって data フィールドの構造が異なります。
そのため type だけ先にパースし、イベントタイプが確定してから data を解析する遅延パースの構成にします。
Go では json.RawMessage を使うと、フィールドの内容を未解析のまま保持しておき、必要なタイミングで解析できます。
DOM ノードは childNodes による入れ子を []*Node の再帰的な型でそのまま表現します。
属性値は文字列・真偽値・数値が混在するため map[string]any で受け取ります。
アクション抽出の流れ
イベント列を先頭から処理し、イベントの種類に応じて「Mirror の更新」か「アクションの生成」かを判断します。
| イベント | 処理 | 理由 |
|---|---|---|
| FullSnapshot | Mirror を初期化 | 記録開始時点の DOM スナップショット。ユーザ操作ではない |
| Mutation | Mirror を差分更新 | クリックや入力の結果として DOM が変化した副作用。ユーザ操作ではない |
| MouseInteraction(Click) | アクション生成 | ユーザが意図してクリックした |
| Input | アクション生成 | ユーザが意図してテキストを入力した |
| Meta | アクション生成 | ページ遷移が発生した |
FullSnapshot が来たら DOM ツリー全体を Mirror に登録し、Mutation が来たら差分を Mirror に反映します。
こうすることで、後続の Click や Input イベントの id フィールドから常に最新のノード情報を取り出せます。
解析に失敗したイベントは読み飛ばし、1 件の失敗で全体の処理を止めないようにします。
アクション変換
click
MouseInteraction には Click 以外に MouseUp・Focus・Blur 等が含まれますが、このうち type=2(Click) がユーザが意図してクリックした操作に対応します。
その他は操作に付随して発生するイベントのため、ここでは除外しておきます。
click アクションにはセレクタに加えてタグ名・表示テキスト・属性も付与します。
セレクタは Mirror からノード情報を取り出して生成し、表示テキストは InnerText でノードの子孫を再帰的に辿って取得します。
InnerText はノードの子孫テキストノードを再帰的に辿って文字列を返す関数で、ボタン等の表示ラベルを取得するために使います。
input
Input はキーストロークのたびに発行され、text にはその時点の入力値が入っています。
Mirror が空になる状況は、FullSnapshot より前に Input イベントが届いた場合です。 この場合はセレクタを生成できないためスキップします。
navigate
Meta イベントの href フィールドをそのまま URL として navigate アクションに変換します。
SPA ではルーティングの変化毎に Meta が発行されるため、複数の navigate アクションが生成されることもあります。
Mirror 未登録ノードへの操作
hidden 属性の要素など、FullSnapshot に含まれないノードへの操作が記録されることがあります。
こうしたノードは Mirror にノード情報がないため CSS セレクタを生成できず、ノード ID をそのまま埋め込んだ [rrweb-id=42] という形式でフォールバックします。
また、rrweb で -1 や 0 は無効な ID として予約されているため、これらはアクションから除外します。
生成されるアクションの例
実際のログイン操作を記録した場合、以下のようなアクションが抽出されます。
アクション抽出結果(JSON)
抽出したアクションは、ユーザがどの画面でどの操作をしたかの分析や、ブラウザ操作の自動化スクリプト生成の入力として使うことができます。
まとめ
今回のブログでは、rrweb のイベント構造と DOM 追跡の仕組みを整理した上で、記録データからユーザ操作(アクション)を抽出する方法を紹介しました。
rrweb はスクリーンキャプチャや画面録画とは異なり、DOM のスナップショットと差分イベントを組み合わせてブラウザ操作を構造化されたイベントとして記録する設計になっています。 この構造があることで、記録済みのデータから「どの要素をいつどのように操作したか」を後から解釈して取り出すことができます。
また、操作対象のノードはイベント内の ID で参照されますが、ID は記録のたびに採番される一時的な番号であるため、CSS セレクタを活用することでアクションに再現性を持たせることができます。
抽出したアクションはユーザ行動の分析やブラウザ操作の自動化スクリプト生成の入力として活用できます。 DOM レベルでの詳細な操作追跡が求められるユースケースに対して rrweb は有効な選択肢で、実際に Datadog RUM・LogRocket・PostHog・Sentry といった有名な SaaS のセッション記録機能のバックエンドとしても広く採用されています。