You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@flagon.apache.org by au...@apache.org on 2026/10/11 23:49:37 UTC

(flagon) branch feat/session-replay-104 created (now 399f80b)

This is an automated email from the ASF dual-hosted git repository.

brucearctor pushed a change to branch feat/session-replay-104
in repository https://gitbox.apache.org/repos/asf/flagon.git


      at 399f80b  feat(userale): add two-layer session replay and DOM mutation telemetry (#104)

This branch includes the following new commits:

     new 399f80b  feat(userale): add two-layer session replay and DOM mutation telemetry (#104)

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(flagon) 01/01: feat(userale): add two-layer session replay and DOM mutation telemetry (#104)

Posted by au...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

brucearctor pushed a commit to branch feat/session-replay-104
in repository https://gitbox.apache.org/repos/asf/flagon.git

commit 399f80b78caad55c558473f93415ead265bac232
Author: Bruce Arctor <50...@users.noreply.github.com>
AuthorDate: Sun Oct 11 16:48:24 2026 -0700

    feat(userale): add two-layer session replay and DOM mutation telemetry (#104)
---
 .../userale/packages/flagon-userale/CHANGELOG.md   |   3 +
 products/userale/packages/flagon-userale/README.md |  62 +-
 .../packages/flagon-userale/src/configure.ts       |   1 +
 .../flagon-userale/src/getInitialSettings.ts       |   2 +
 .../userale/packages/flagon-userale/src/main.ts    |  36 ++
 .../packages/flagon-userale/src/replay/index.ts    | 654 +++++++++++++++++++++
 .../userale/packages/flagon-userale/src/types.d.ts |   1 +
 .../flagon-userale/test/spec/replay.spec.ts        | 311 ++++++++++
 8 files changed, 1052 insertions(+), 18 deletions(-)

diff --git a/products/userale/packages/flagon-userale/CHANGELOG.md b/products/userale/packages/flagon-userale/CHANGELOG.md
index 4bf9866..2a1773e 100644
--- a/products/userale/packages/flagon-userale/CHANGELOG.md
+++ b/products/userale/packages/flagon-userale/CHANGELOG.md
@@ -36,6 +36,9 @@ Apache Flagon UserALE.js (Unreleased)
   - Added SSR safety guards for `window` and `document` globals and moved `@bufbuild/protobuf` to `devDependencies` to keep the runtime bundle zero-dependency
   - Upgraded `tsup` bundler from v5 to v8 and added `"files"` allowlist to `package.json` (#136, #146)
   - Updated Apache Flagon Distill schema to seamlessly ingest UserALE v2.3/v2.4/v2.5 flat JSON, Protobuf v1 JSON (`LogBatch`), and OTLP/JSON (`ExportLogsServiceRequest`) payloads
+  - Zero-dependency browser metadata detection (`detectBrowser`) using User-Agent Client Hints with regex fallback (#108)
+  - Configurable event type and log type filtering in the browser extension (`flagon-userale-ext`) options UI (#107, #109)
+  - Two-layer Session Replay & Behavioral DOM Telemetry (`startSessionReplay`, `flushSessionReplay`, `stopSessionReplay`, `ReplayRingBuffer`, `RageClickDetector`, `getTargetRect`) with throttled `MutationObserver` summaries, rage-click detection, and `rrweb`-compatible ring-buffer replay bridge correlated via `sessionId` and OpenTelemetry `traceId`/`spanId` (#104)
 
 Apache Flagon UserALE.js 2.4.0 (2024-03-21)
 -------------------------------
diff --git a/products/userale/packages/flagon-userale/README.md b/products/userale/packages/flagon-userale/README.md
index 4e0b650..ef6ebf3 100644
--- a/products/userale/packages/flagon-userale/README.md
+++ b/products/userale/packages/flagon-userale/README.md
@@ -151,6 +151,7 @@ The complete list of configurable parameters that can be configured via `userale
 | otlpEndpoint      | OpenTelemetry Collector HTTP endpoint (auto-appends `/v1/logs` when path is empty/root) | null                  |
 | exporter          | Custom `LogExporter` instance overriding built-in exporters                             | null                  |
 | traceContext      | Callback returning active W3C `{ traceId, spanId }` for OTLP correlation                | null                  |
+| observeMutations  | Enable throttled `MutationObserver` structural DOM telemetry (`#104`)                   | false                 |
 
 If you have included UserALE as a `script-tag` in your project, you can use HTML data parameters to pass configuration options to the library through the script tag. For example:
 
@@ -166,26 +167,27 @@ If you have included UserALE as a `script-tag` in your project, you can use HTML
 
 You have access to the same parameters listed above, however, naming conventions vary slightly for use in HTML:
 
-| Param                 | Description                                                              | Default               |
-| --------------------- | ------------------------------------------------------------------------ | --------------------- |
-| data-url              | Logging URL                                                              | http://localhost:8000 |
-| data-autostart        | Should UserALE start on page load                                        | true                  |
-| data-interval         | Delay between transmit checks                                            | 5000 (ms)             |
-| data-threshold        | Minimum number of logs to send                                           | 5                     |
-| data-user             | User identifier                                                          | null                  |
-| data-version          | Application version identifier                                           | null                  |
-| data-log-details      | Toggle detailed logs (keys pressed and input/change values)              | false                 |
-| data-resolution       | Delay between instances of high frequency logs (mouseover, scroll, etc.) | 500 (ms)              |
-| data-user-from-params | Query param in the page URL to fetch userId from                         | null                  |
-| data-tool             | Name of tool being logged                                                | null                  |
-| data-auth             | `Authorization` header value                                             | null                  |
-| data-api-key          | `x-api-key` header value                                                 | null                  |
-| data-headers          | JSON-encoded object of custom HTTP headers                               | null                  |
-| data-exporter         | Exporter format (`http-json`, `otlp`, `proto-json`, `websocket`)         | `http-json`           |
-| data-otlp-endpoint    | OpenTelemetry Collector HTTP endpoint                                    | null                  |
+| Param                  | Description                                                              | Default               |
+| ---------------------- | ------------------------------------------------------------------------ | --------------------- |
+| data-url               | Logging URL                                                              | http://localhost:8000 |
+| data-autostart         | Should UserALE start on page load                                        | true                  |
+| data-interval          | Delay between transmit checks                                            | 5000 (ms)             |
+| data-threshold         | Minimum number of logs to send                                           | 5                     |
+| data-user              | User identifier                                                          | null                  |
+| data-version           | Application version identifier                                           | null                  |
+| data-log-details       | Toggle detailed logs (keys pressed and input/change values)              | false                 |
+| data-resolution        | Delay between instances of high frequency logs (mouseover, scroll, etc.) | 500 (ms)              |
+| data-user-from-params  | Query param in the page URL to fetch userId from                         | null                  |
+| data-tool              | Name of tool being logged                                                | null                  |
+| data-auth              | `Authorization` header value                                             | null                  |
+| data-api-key           | `x-api-key` header value                                                 | null                  |
+| data-headers           | JSON-encoded object of custom HTTP headers                               | null                  |
+| data-exporter          | Exporter format (`http-json`, `otlp`, `proto-json`, `websocket`)         | `http-json`           |
+| data-otlp-endpoint     | OpenTelemetry Collector HTTP endpoint                                    | null                  |
+| data-observe-mutations | Enable throttled `MutationObserver` structural DOM telemetry (`#104`)    | `false`               |
 
 If you are using our [WebExtension](https://github.com/apache/flagon/blob/master/products/userale/packages/flagon-userale-ext/),
-you can modify these parameters (including OAuth/API Key authentication and Exporter Format) via the extension's `Options` page.
+you can modify these parameters (including OAuth/API Key authentication, Exporter Format, and Event/Log Type filters) via the extension's `Options` page.
 
 ### Authentication, Custom Headers, CORS & WebSocket Limitations
 
@@ -244,6 +246,30 @@ userale.options({
 
 See [`example/otel-demo`](https://github.com/apache/flagon/tree/master/products/userale/example/otel-demo) for a complete Docker Compose stack with **OpenTelemetry Collector**, **Grafana Loki**, **Grafana Tempo**, and a pre-built **Grafana Dashboard** featuring click-to-trace correlation.
 
+### Session Replay & Behavioral DOM Telemetry (`#104`)
+
+UserALE provides a two-layer session replay architecture (`startSessionReplay`, `flushSessionReplay`, `stopSessionReplay`) that keeps the core bundle dependency-free while supporting both structural DOM telemetry and `rrweb`-compatible visual playback:
+
+1. **Layer 1 (Built-in `MutationObserver`, Bounding-Box Geometry, & Rage-Click Detection)**:
+   - Aggregates structural DOM mutations (`addedCount`, `removedCount`, `attributeCount`, `targets`) without capturing PII text content, while automatically ignoring subtrees marked with `data-userale-ignore`.
+   - Detects **Rage Clicks** (3+ rapid clicks on the same target selector within `1000ms` and `30px` radius), emitting `logType: "custom", type: "rage_click"` with element bounding-box geometry (`targetRect`).
+2. **Layer 2 (`rrweb` Ring-Buffer Visual Replay Bridge)**:
+   - Pass `rrweb.record` as `recorder` to maintain a rolling in-memory ring buffer (`maxDurationMs: 30000`) that preserves `Meta` (`type: 4`) and `FullSnapshot` (`type: 2`) anchor frames.
+   - Automatically flushes the visual replay tagged with `sessionId` and active OpenTelemetry `traceId` / `spanId` whenever a rage-click, runtime `error`, or manual `flushSessionReplay()` occurs.
+
+```js
+import * as userale from "flagon-userale";
+import * as rrweb from "rrweb";
+
+userale.startSessionReplay({
+  recorder: rrweb.record,
+  maxDurationMs: 30_000,
+  observeMutations: true,
+  detectRageClicks: true,
+  flushOnError: true,
+});
+```
+
 ## Usage
 
 Including UserALE in your project as a `module` attaches the UserALE script as an object to the page.
diff --git a/products/userale/packages/flagon-userale/src/configure.ts b/products/userale/packages/flagon-userale/src/configure.ts
index 2c42665..6ecb851 100644
--- a/products/userale/packages/flagon-userale/src/configure.ts
+++ b/products/userale/packages/flagon-userale/src/configure.ts
@@ -37,6 +37,7 @@ export class Configuration {
   public httpSessionId: Settings.SessionId = null;
   public logCountThreshold: number = 0;
   public logDetails: boolean = false;
+  public observeMutations: boolean = false;
   public on: boolean = false;
   public otlpEndpoint: Settings.OtlpEndpoint = null;
   public resolution: number = 0;
diff --git a/products/userale/packages/flagon-userale/src/getInitialSettings.ts b/products/userale/packages/flagon-userale/src/getInitialSettings.ts
index 55cbe50..5e0c8fc 100644
--- a/products/userale/packages/flagon-userale/src/getInitialSettings.ts
+++ b/products/userale/packages/flagon-userale/src/getInitialSettings.ts
@@ -45,6 +45,7 @@ export function getInitialSettings(): Settings.Config {
       httpSessionId: null,
       logCountThreshold: +5,
       logDetails: false,
+      observeMutations: false,
       otlpEndpoint: null,
       resolution: +500,
       sessionId: sessionId,
@@ -106,6 +107,7 @@ export function getInitialSettings(): Settings.Config {
     httpSessionId: httpSessionId,
     logCountThreshold: +(get("data-threshold") || 5),
     logDetails: get("data-log-details") === "true" ? true : false,
+    observeMutations: get("data-observe-mutations") === "true" ? true : false,
     otlpEndpoint: otlpEndpoint,
     resolution: +(get("data-resolution") || 500),
     sessionId: get("data-session") || sessionId,
diff --git a/products/userale/packages/flagon-userale/src/main.ts b/products/userale/packages/flagon-userale/src/main.ts
index c363537..2225191 100644
--- a/products/userale/packages/flagon-userale/src/main.ts
+++ b/products/userale/packages/flagon-userale/src/main.ts
@@ -36,6 +36,15 @@ import { version as userAleVersion } from "../package.json";
 import { Configuration } from "@/configure";
 import { attachHandlers } from "@/attachHandlers";
 import { initSender, registerExporter } from "@/sendLogs";
+import {
+  flushSessionReplay,
+  getActiveReplayRingBuffer,
+  getTargetRect,
+  RageClickDetector,
+  ReplayRingBuffer,
+  startSessionReplay,
+  stopSessionReplay,
+} from "@/replay";
 
 import type { Settings, Logging } from "@/types";
 
@@ -71,6 +80,22 @@ export {
   detectBrowser as detectBrowser,
 } from "@/packageLogs";
 export { registerExporter } from "@/sendLogs";
+export {
+  flushSessionReplay,
+  getActiveReplayRingBuffer,
+  getTargetRect,
+  RageClickDetector,
+  ReplayRingBuffer,
+  startSessionReplay,
+  stopSessionReplay,
+} from "@/replay";
+export type {
+  ReplayEnvelope,
+  ReplayFrame,
+  ReplayRecorderFn,
+  SessionReplayOptions,
+  TargetRect,
+} from "@/replay";
 export {
   buildHeaders,
   buildOtlpResourceAttributes,
@@ -132,6 +157,9 @@ function setup(config: Configuration) {
         attachHandlers(config);
         initSender(logs, config);
         started = config.on = true;
+        if (config.observeMutations) {
+          startSessionReplay({ observeMutations: true }, config);
+        }
         if (typeof window !== "undefined" && typeof document !== "undefined") {
           const loadDuration = Math.max(
             1,
@@ -168,6 +196,9 @@ export function start(): void {
     if (!wasStarted) {
       attachHandlers(config);
       initSender(logs, config);
+      if (config.observeMutations) {
+        startSessionReplay({ observeMutations: true }, config);
+      }
     }
   }
 }
@@ -178,6 +209,7 @@ export function start(): void {
 export function stop(): void {
   started = config.on = false;
   config.update({ autostart: false });
+  stopSessionReplay();
 }
 
 /**
@@ -222,6 +254,10 @@ if (typeof window !== "undefined") {
     registerAuthCallback,
     registerHeadersCallback,
     registerExporter,
+    startSessionReplay,
+    stopSessionReplay,
+    flushSessionReplay,
+    getTargetRect,
     addCallbacks,
     removeCallbacks,
     packageLog,
diff --git a/products/userale/packages/flagon-userale/src/replay/index.ts b/products/userale/packages/flagon-userale/src/replay/index.ts
new file mode 100644
index 0000000..ff4483a
--- /dev/null
+++ b/products/userale/packages/flagon-userale/src/replay/index.ts
@@ -0,0 +1,654 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Configuration } from "@/configure";
+import { buildHeaders } from "@/exporters/headers";
+import { getSelector, packageCustomLog } from "@/packageLogs";
+import type { Logging } from "@/types";
+
+export interface TargetRect {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+}
+
+/**
+ * Standard rrweb-compatible event frame structure.
+ * In rrweb: type 2 = FullSnapshot, type 3 = IncrementalSnapshot, type 4 = Meta.
+ */
+export interface ReplayFrame {
+  type: number;
+  data: unknown;
+  timestamp: number;
+  [key: string]: unknown;
+}
+
+export type ReplayRecorderFn = (options: {
+  emit: (event: ReplayFrame, isCheckout?: boolean) => void;
+  checkoutEveryNms?: number;
+  maskAllInputs?: boolean;
+  blockSelector?: string;
+  maskTextSelector?: string;
+  [key: string]: unknown;
+}) => (() => void) | undefined;
+
+export interface ReplayEnvelope {
+  logType: "replay";
+  type: "session_replay";
+  trigger: string;
+  sessionId: string | null;
+  httpSessionId: string | null;
+  browserSessionId: string | null;
+  pageUrl: string;
+  startTime: number;
+  endTime: number;
+  frameCount: number;
+  frames: Array<ReplayFrame>;
+  traceId?: string;
+  spanId?: string;
+}
+
+export interface SessionReplayOptions {
+  /**
+   * Optional rrweb.record-compatible function for Layer 2 visual DOM replay.
+   */
+  recorder?: ReplayRecorderFn;
+  /**
+   * Rolling ring-buffer duration in milliseconds (default: 30000).
+   */
+  maxDurationMs?: number;
+  /**
+   * Maximum number of frames retained in the ring buffer (default: 2000).
+   */
+  maxFrames?: number;
+  /**
+   * Enable Layer 1 MutationObserver structural DOM telemetry (default: true).
+   */
+  observeMutations?: boolean;
+  /**
+   * Enable automatic rage-click detection and replay trigger (default: true).
+   */
+  detectRageClicks?: boolean;
+  /**
+   * Enable automatic window error / unhandledrejection replay trigger (default: true).
+   */
+  flushOnError?: boolean;
+  /**
+   * CSS selector for DOM subtrees ignored by MutationObserver and rrweb (default: "[data-userale-ignore], .userale-ignore").
+   */
+  ignoreSelector?: string;
+  /**
+   * CSS selector for elements whose text should be masked in rrweb (default: "[data-userale-mask], .userale-mask").
+   */
+  maskTextSelector?: string;
+  /**
+   * Mask all input values in rrweb (default: true).
+   */
+  maskAllInputs?: boolean;
+  /**
+   * Optional custom callback invoked when a replay envelope is flushed.
+   * If omitted, flushes via POST to config.replayEndpoint || config.url (or packageCustomLog when smaller than 48KB).
+   */
+  onFlush?: (envelope: ReplayEnvelope) => void | Promise<void>;
+}
+
+const DEFAULT_IGNORE_SELECTOR = "[data-userale-ignore], .userale-ignore";
+const DEFAULT_MASK_SELECTOR = "[data-userale-mask], .userale-mask";
+const MAX_KEEPALIVE_BYTES = 48 * 1024;
+
+/**
+ * Safely extracts the bounding client rectangle for an event target on discrete
+ * user interactions (click, dblclick, change, submit).
+ */
+export function getTargetRect(target: EventTarget | null): TargetRect | null {
+  try {
+    if (
+      target &&
+      typeof (target as Element).getBoundingClientRect === "function"
+    ) {
+      const rect = (target as Element).getBoundingClientRect();
+      if (
+        rect &&
+        Number.isFinite(rect.x) &&
+        Number.isFinite(rect.y) &&
+        Number.isFinite(rect.width) &&
+        Number.isFinite(rect.height)
+      ) {
+        return {
+          x: Math.round(rect.x),
+          y: Math.round(rect.y),
+          width: Math.round(rect.width),
+          height: Math.round(rect.height),
+        };
+      }
+    }
+  } catch {
+    // Ignore errors in non-DOM environments
+  }
+  return null;
+}
+
+/**
+ * Rolling ring buffer for rrweb-compatible DOM replay frames.
+ * Preserves the most recent Meta (type 4) and FullSnapshot (type 2) anchor frames
+ * so flushed slices are always playable by rrweb-player.
+ */
+export class ReplayRingBuffer {
+  private maxDurationMs: number;
+  private maxFrames: number;
+  private frames: Array<ReplayFrame> = [];
+  private anchorMeta: ReplayFrame | null = null;
+  private anchorFullSnapshot: ReplayFrame | null = null;
+
+  constructor(maxDurationMs = 30_000, maxFrames = 2_000) {
+    this.maxDurationMs = Math.max(1_000, maxDurationMs);
+    this.maxFrames = Math.max(10, maxFrames);
+  }
+
+  public push(frame: ReplayFrame, isCheckout = false): void {
+    const ts =
+      typeof frame.timestamp === "number" && Number.isFinite(frame.timestamp)
+        ? frame.timestamp
+        : Date.now();
+    const normalized: ReplayFrame = { ...frame, timestamp: ts };
+
+    if (isCheckout) {
+      this.frames = [];
+      this.anchorMeta = null;
+      this.anchorFullSnapshot = null;
+    }
+
+    if (normalized.type === 4) {
+      this.anchorMeta = normalized;
+    } else if (normalized.type === 2) {
+      this.anchorFullSnapshot = normalized;
+    }
+
+    this.frames.push(normalized);
+    this.prune(ts);
+  }
+
+  public prune(now = Date.now()): void {
+    const cutoff = now - this.maxDurationMs;
+    let firstValidIdx = 0;
+    while (
+      firstValidIdx < this.frames.length &&
+      this.frames[firstValidIdx].timestamp < cutoff
+    ) {
+      firstValidIdx++;
+    }
+    if (firstValidIdx > 0) {
+      this.frames = this.frames.slice(firstValidIdx);
+    }
+    if (this.frames.length > this.maxFrames) {
+      this.frames = this.frames.slice(this.frames.length - this.maxFrames);
+    }
+  }
+
+  public getSnapshotFrames(): Array<ReplayFrame> {
+    this.prune(
+      this.frames.length > 0
+        ? this.frames[this.frames.length - 1].timestamp
+        : Date.now(),
+    );
+    if (this.frames.length === 0) {
+      return [];
+    }
+
+    const result: Array<ReplayFrame> = [];
+    const hasFullSnapshotInWindow = this.frames.some((f) => f.type === 2);
+
+    if (!hasFullSnapshotInWindow && this.anchorFullSnapshot) {
+      if (
+        this.anchorMeta &&
+        this.anchorMeta.timestamp <= this.anchorFullSnapshot.timestamp
+      ) {
+        result.push(this.anchorMeta);
+      }
+      result.push(this.anchorFullSnapshot);
+    }
+
+    for (const f of this.frames) {
+      if (result.length > 0 && result[result.length - 1] === f) {
+        continue;
+      }
+      result.push(f);
+    }
+    return result;
+  }
+
+  public clear(): void {
+    this.frames = [];
+    this.anchorMeta = null;
+    this.anchorFullSnapshot = null;
+  }
+
+  public size(): number {
+    return this.frames.length;
+  }
+}
+
+interface ClickSample {
+  target: string;
+  timestamp: number;
+  x: number | null;
+  y: number | null;
+}
+
+/**
+ * Detects rage clicks (3+ rapid clicks on the same selector within 1000ms and 30px radius).
+ */
+export class RageClickDetector {
+  private clicks: Array<ClickSample> = [];
+  private windowMs: number;
+  private threshold: number;
+  private radiusPx: number;
+
+  constructor(windowMs = 1_000, threshold = 3, radiusPx = 30) {
+    this.windowMs = windowMs;
+    this.threshold = threshold;
+    this.radiusPx = radiusPx;
+  }
+
+  public recordClick(sample: ClickSample): {
+    isRageClick: boolean;
+    clickCount: number;
+  } {
+    const cutoff = sample.timestamp - this.windowMs;
+    this.clicks = this.clicks.filter((c) => c.timestamp >= cutoff);
+    this.clicks.push(sample);
+
+    const matching = this.clicks.filter((c) => {
+      if (c.target !== sample.target) {
+        return false;
+      }
+      if (
+        c.x !== null &&
+        c.y !== null &&
+        sample.x !== null &&
+        sample.y !== null
+      ) {
+        const dx = c.x - sample.x;
+        const dy = c.y - sample.y;
+        return Math.hypot(dx, dy) <= this.radiusPx;
+      }
+      return true;
+    });
+
+    if (matching.length >= this.threshold) {
+      const count = matching.length;
+      // Reset clicks for this target so we emit one event per burst
+      this.clicks = this.clicks.filter((c) => c.target !== sample.target);
+      return { isRageClick: true, clickCount: count };
+    }
+
+    return { isRageClick: false, clickCount: matching.length };
+  }
+
+  public reset(): void {
+    this.clicks = [];
+  }
+}
+
+function isIgnoredNode(node: unknown, ignoreSelector: string): boolean {
+  if (!node || typeof node !== "object") {
+    return false;
+  }
+  const candidate = node as {
+    nodeType?: number;
+    closest?: (selector: string) => Element | null;
+    parentElement?: { closest?: (selector: string) => Element | null } | null;
+  };
+  const el =
+    typeof candidate.closest === "function"
+      ? candidate
+      : candidate.parentElement &&
+          typeof candidate.parentElement.closest === "function"
+        ? candidate.parentElement
+        : null;
+  if (!el || typeof el.closest !== "function") {
+    return false;
+  }
+  try {
+    return Boolean(el.closest(ignoreSelector));
+  } catch {
+    return false;
+  }
+}
+
+let activeStopFn: (() => void) | null = null;
+let activeRingBuffer: ReplayRingBuffer | null = null;
+let activeFlushFn:
+  ((trigger: string) => Promise<ReplayEnvelope | null>) | null = null;
+
+/**
+ * Flushes the current Layer 2 replay ring buffer (if active) with the given trigger reason.
+ */
+export async function flushSessionReplay(
+  trigger = "manual",
+): Promise<ReplayEnvelope | null> {
+  if (activeFlushFn) {
+    return activeFlushFn(trigger);
+  }
+  return null;
+}
+
+/**
+ * Stops any active session replay / DOM mutation observer and resets internal state.
+ */
+export function stopSessionReplay(): void {
+  if (activeStopFn) {
+    activeStopFn();
+    activeStopFn = null;
+  }
+  activeRingBuffer = null;
+  activeFlushFn = null;
+}
+
+/**
+ * Starts Layer 1 (MutationObserver + RageClick friction telemetry) and optional
+ * Layer 2 (rrweb-compatible ring buffer replay bridge).
+ * Returns a cleanup function that stops observation and recording.
+ */
+export function startSessionReplay(
+  options: SessionReplayOptions = {},
+  config: Configuration = Configuration.getInstance(),
+): () => void {
+  stopSessionReplay();
+
+  const maxDurationMs = options.maxDurationMs ?? 30_000;
+  const maxFrames = options.maxFrames ?? 2_000;
+  const observeMutations = options.observeMutations ?? true;
+  const detectRageClicks = options.detectRageClicks ?? true;
+  const flushOnError = options.flushOnError ?? true;
+  const ignoreSelector = options.ignoreSelector ?? DEFAULT_IGNORE_SELECTOR;
+  const maskTextSelector = options.maskTextSelector ?? DEFAULT_MASK_SELECTOR;
+  const maskAllInputs = options.maskAllInputs ?? true;
+
+  const ringBuffer = new ReplayRingBuffer(maxDurationMs, maxFrames);
+  activeRingBuffer = ringBuffer;
+
+  let stopRecorder: (() => void) | undefined;
+  if (typeof options.recorder === "function") {
+    stopRecorder = options.recorder({
+      emit: (event: ReplayFrame, isCheckout?: boolean) => {
+        ringBuffer.push(event, isCheckout);
+      },
+      checkoutEveryNms: maxDurationMs,
+      maskAllInputs,
+      blockSelector: ignoreSelector,
+      maskTextSelector,
+    });
+  }
+
+  const doFlush = async (trigger: string): Promise<ReplayEnvelope | null> => {
+    const frames = ringBuffer.getSnapshotFrames();
+    if (frames.length === 0) {
+      return null;
+    }
+
+    const traceCtx =
+      typeof config.traceContext === "function" ? config.traceContext() : null;
+    const envelope: ReplayEnvelope = {
+      logType: "replay",
+      type: "session_replay",
+      trigger,
+      sessionId: config.sessionId,
+      httpSessionId: config.httpSessionId,
+      browserSessionId: config.browserSessionId,
+      pageUrl:
+        typeof self !== "undefined" && self.location ? self.location.href : "",
+      startTime: frames[0].timestamp,
+      endTime: frames[frames.length - 1].timestamp,
+      frameCount: frames.length,
+      frames,
+      ...(traceCtx?.traceId ? { traceId: traceCtx.traceId } : {}),
+      ...(traceCtx?.spanId ? { spanId: traceCtx.spanId } : {}),
+    };
+
+    if (typeof options.onFlush === "function") {
+      await options.onFlush(envelope);
+      return envelope;
+    }
+
+    const serialized = JSON.stringify(envelope);
+    if (serialized.length <= MAX_KEEPALIVE_BYTES) {
+      packageCustomLog(
+        {
+          logType: "replay",
+          type: "session_replay",
+          trigger,
+          frameCount: envelope.frameCount,
+          startTime: envelope.startTime,
+          endTime: envelope.endTime,
+        },
+        () =>
+          ({
+            frames: envelope.frames as unknown as Logging.JSONObject[],
+          }) as unknown as Logging.JSONObject,
+        false,
+      );
+    } else if (typeof fetch === "function" && config.url) {
+      // Payloads > 48KB bypass keepalive so the browser's 64KB keepalive limit is never exceeded
+      try {
+        await fetch(config.url, {
+          method: "POST",
+          headers: buildHeaders(config),
+          body: serialized,
+          keepalive: false,
+        });
+      } catch {
+        // Ignore network errors in background replay flush
+      }
+    }
+
+    return envelope;
+  };
+
+  activeFlushFn = doFlush;
+
+  // Layer 1A: Throttled MutationObserver
+  let observer: MutationObserver | null = null;
+  let mutationTimer: ReturnType<typeof setTimeout> | null = null;
+  let pendingAdded = 0;
+  let pendingRemoved = 0;
+  let pendingAttributes = 0;
+  const pendingTargets = new Set<string>();
+
+  const flushMutationSummary = () => {
+    mutationTimer = null;
+    if (pendingAdded === 0 && pendingRemoved === 0 && pendingAttributes === 0) {
+      return;
+    }
+    const addedCount = pendingAdded;
+    const removedCount = pendingRemoved;
+    const attributeCount = pendingAttributes;
+    const targets = Array.from(pendingTargets).slice(0, 10);
+
+    pendingAdded = 0;
+    pendingRemoved = 0;
+    pendingAttributes = 0;
+    pendingTargets.clear();
+
+    if (config.on) {
+      packageCustomLog(
+        {
+          logType: "mutation",
+          type: "mutation",
+          target: targets[0] ?? "#document",
+        },
+        () => ({
+          addedCount,
+          removedCount,
+          attributeCount,
+          targets,
+        }),
+        false,
+      );
+    }
+  };
+
+  if (
+    observeMutations &&
+    typeof MutationObserver !== "undefined" &&
+    typeof document !== "undefined" &&
+    document.documentElement
+  ) {
+    observer = new MutationObserver((records) => {
+      for (const rec of records) {
+        if (isIgnoredNode(rec.target, ignoreSelector)) {
+          continue;
+        }
+        if (rec.type === "childList") {
+          let validAdded = 0;
+          for (let i = 0; i < rec.addedNodes.length; i++) {
+            if (!isIgnoredNode(rec.addedNodes[i], ignoreSelector)) {
+              validAdded++;
+            }
+          }
+          let validRemoved = 0;
+          for (let i = 0; i < rec.removedNodes.length; i++) {
+            if (!isIgnoredNode(rec.removedNodes[i], ignoreSelector)) {
+              validRemoved++;
+            }
+          }
+          if (validAdded === 0 && validRemoved === 0) {
+            continue;
+          }
+          pendingAdded += validAdded;
+          pendingRemoved += validRemoved;
+        } else if (rec.type === "attributes") {
+          pendingAttributes += 1;
+        }
+
+        if (rec.target && pendingTargets.size < 10) {
+          const sel = getSelector(rec.target);
+          if (sel && sel !== "Unknown") {
+            pendingTargets.add(sel);
+          }
+        }
+      }
+
+      if (
+        (pendingAdded > 0 || pendingRemoved > 0 || pendingAttributes > 0) &&
+        mutationTimer === null
+      ) {
+        const delay = Math.max(20, config.resolution || 500);
+        mutationTimer = setTimeout(flushMutationSummary, delay);
+      }
+    });
+
+    observer.observe(document.documentElement, {
+      childList: true,
+      subtree: true,
+      attributes: true,
+      attributeFilter: ["class", "id", "hidden", "disabled", "aria-expanded"],
+    });
+  }
+
+  // Layer 1B: Rage-Click Friction Detector
+  const rageDetector = new RageClickDetector();
+  const onClickCapture = (e: MouseEvent) => {
+    if (!config.on || !e.target) {
+      return;
+    }
+    if (isIgnoredNode(e.target, ignoreSelector)) {
+      return;
+    }
+    const targetSel = getSelector(e.target);
+    const now = Date.now();
+    const { isRageClick, clickCount } = rageDetector.recordClick({
+      target: targetSel,
+      timestamp: now,
+      x: typeof e.clientX === "number" ? e.clientX : null,
+      y: typeof e.clientY === "number" ? e.clientY : null,
+    });
+
+    if (isRageClick) {
+      const rect = getTargetRect(e.target);
+      packageCustomLog(
+        {
+          logType: "custom",
+          type: "rage_click",
+          target: targetSel,
+        },
+        () => ({
+          clickCount,
+          ...(rect
+            ? { targetRect: rect as unknown as Logging.JSONObject }
+            : {}),
+        }),
+        true,
+      );
+      void doFlush("rage_click");
+    }
+  };
+
+  if (detectRageClicks && typeof document !== "undefined") {
+    document.addEventListener("click", onClickCapture, true);
+  }
+
+  // Error trigger for Layer 2 replay flush
+  const onWindowError = () => {
+    void doFlush("error");
+  };
+  if (
+    flushOnError &&
+    typeof self !== "undefined" &&
+    typeof self.addEventListener === "function"
+  ) {
+    self.addEventListener("error", onWindowError);
+  }
+
+  const cleanup = () => {
+    if (mutationTimer !== null) {
+      clearTimeout(mutationTimer);
+      mutationTimer = null;
+    }
+    if (observer) {
+      observer.disconnect();
+      observer = null;
+    }
+    if (detectRageClicks && typeof document !== "undefined") {
+      document.removeEventListener("click", onClickCapture, true);
+    }
+    if (
+      flushOnError &&
+      typeof self !== "undefined" &&
+      typeof self.removeEventListener === "function"
+    ) {
+      self.removeEventListener("error", onWindowError);
+    }
+    if (typeof stopRecorder === "function") {
+      stopRecorder();
+      stopRecorder = undefined;
+    }
+    rageDetector.reset();
+    ringBuffer.clear();
+  };
+
+  activeStopFn = cleanup;
+  return cleanup;
+}
+
+/**
+ * Returns the active ReplayRingBuffer instance if session replay is running.
+ */
+export function getActiveReplayRingBuffer(): ReplayRingBuffer | null {
+  return activeRingBuffer;
+}
diff --git a/products/userale/packages/flagon-userale/src/types.d.ts b/products/userale/packages/flagon-userale/src/types.d.ts
index 9ba7f50..9a1f7d5 100644
--- a/products/userale/packages/flagon-userale/src/types.d.ts
+++ b/products/userale/packages/flagon-userale/src/types.d.ts
@@ -74,6 +74,7 @@ export declare namespace Settings {
     httpSessionId: SessionId;
     logCountThreshold: number;
     logDetails: boolean;
+    observeMutations?: boolean;
     on?: boolean;
     otlpEndpoint?: OtlpEndpoint;
     resolution: number;
diff --git a/products/userale/packages/flagon-userale/test/spec/replay.spec.ts b/products/userale/packages/flagon-userale/test/spec/replay.spec.ts
new file mode 100644
index 0000000..6f51762
--- /dev/null
+++ b/products/userale/packages/flagon-userale/test/spec/replay.spec.ts
@@ -0,0 +1,311 @@
+/**
+ * @jest-environment jsdom
+ */
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import "whatwg-fetch";
+import { jest } from "@jest/globals";
+import { Configuration } from "@/configure";
+import { initPackager, logs } from "@/packageLogs";
+import {
+  flushSessionReplay,
+  getActiveReplayRingBuffer,
+  getTargetRect,
+  RageClickDetector,
+  ReplayRingBuffer,
+  startSessionReplay,
+  stopSessionReplay,
+  type ReplayEnvelope,
+  type ReplayFrame,
+} from "@/replay";
+
+const config = Configuration.getInstance();
+
+describe("Session Replay & Behavioral DOM Telemetry (#104)", () => {
+  beforeEach(() => {
+    stopSessionReplay();
+    config.reset();
+    config.update({
+      on: true,
+      sessionId: "session_replay_test",
+      httpSessionId: "http_session_test",
+      resolution: 30,
+    });
+    initPackager([], config);
+    document.body.innerHTML = "";
+  });
+
+  afterEach(() => {
+    stopSessionReplay();
+  });
+
+  describe("getTargetRect", () => {
+    it("extracts rounded bounding box coordinates from an element", () => {
+      const btn = document.createElement("button");
+      btn.getBoundingClientRect = () =>
+        ({
+          x: 12.4,
+          y: 48.6,
+          width: 120.2,
+          height: 36.8,
+        }) as DOMRect;
+      expect(getTargetRect(btn)).toEqual({
+        x: 12,
+        y: 49,
+        width: 120,
+        height: 37,
+      });
+    });
+
+    it("returns null for null or non-element targets", () => {
+      expect(getTargetRect(null)).toBeNull();
+      expect(getTargetRect({} as EventTarget)).toBeNull();
+    });
+  });
+
+  describe("ReplayRingBuffer", () => {
+    it("retains the latest Meta (type 4) and FullSnapshot (type 2) anchors when pruning old incremental frames", () => {
+      const buffer = new ReplayRingBuffer(5_000, 100);
+      // Initial Meta + FullSnapshot at t=1000
+      buffer.push({
+        type: 4,
+        data: { href: "https://example.com" },
+        timestamp: 1_000,
+      });
+      buffer.push({ type: 2, data: { node: "root" }, timestamp: 1_010 });
+      // Early incremental frame at t=2000 (will be pruned when t=8000 arrives)
+      buffer.push({ type: 3, data: { id: 1 }, timestamp: 2_000 });
+      // Recent incremental frames at t=6000 and t=8000
+      buffer.push({ type: 3, data: { id: 2 }, timestamp: 6_000 });
+      buffer.push({ type: 3, data: { id: 3 }, timestamp: 8_000 });
+
+      const frames = buffer.getSnapshotFrames();
+      // Should include the preserved Meta + FullSnapshot anchors + the 2 in-window incremental frames
+      expect(frames.map((f) => f.type)).toEqual([4, 2, 3, 3]);
+      expect((frames[2].data as { id: number }).id).toBe(2);
+      expect((frames[3].data as { id: number }).id).toBe(3);
+    });
+
+    it("resets buffer on checkout FullSnapshot", () => {
+      const buffer = new ReplayRingBuffer(10_000, 100);
+      buffer.push({ type: 2, data: { v: 1 }, timestamp: 1_000 });
+      buffer.push({ type: 3, data: { v: 2 }, timestamp: 2_000 });
+      buffer.push({ type: 2, data: { v: 3 }, timestamp: 3_000 }, true);
+
+      const frames = buffer.getSnapshotFrames();
+      expect(frames).toHaveLength(1);
+      expect((frames[0].data as { v: number }).v).toBe(3);
+    });
+  });
+
+  describe("RageClickDetector", () => {
+    it("detects 3 rapid clicks on the same target within radius and window", () => {
+      const detector = new RageClickDetector(1_000, 3, 30);
+      expect(
+        detector.recordClick({
+          target: "button#pay",
+          timestamp: 1_000,
+          x: 100,
+          y: 100,
+        }).isRageClick,
+      ).toBe(false);
+      expect(
+        detector.recordClick({
+          target: "button#pay",
+          timestamp: 1_200,
+          x: 105,
+          y: 102,
+        }).isRageClick,
+      ).toBe(false);
+      const third = detector.recordClick({
+        target: "button#pay",
+        timestamp: 1_450,
+        x: 98,
+        y: 104,
+      });
+      expect(third).toEqual({ isRageClick: true, clickCount: 3 });
+    });
+
+    it("does not trigger when clicks are far apart in space or time", () => {
+      const detector = new RageClickDetector(1_000, 3, 30);
+      detector.recordClick({
+        target: "div#canvas",
+        timestamp: 1_000,
+        x: 10,
+        y: 10,
+      });
+      detector.recordClick({
+        target: "div#canvas",
+        timestamp: 1_200,
+        x: 200,
+        y: 200,
+      });
+      const third = detector.recordClick({
+        target: "div#canvas",
+        timestamp: 1_400,
+        x: 400,
+        y: 400,
+      });
+      expect(third.isRageClick).toBe(false);
+    });
+  });
+
+  describe("startSessionReplay (Layer 1 + Layer 2 integration)", () => {
+    it("aggregates structural DOM mutations while ignoring [data-userale-ignore] subtrees", async () => {
+      startSessionReplay(
+        { observeMutations: true, detectRageClicks: false },
+        config,
+      );
+
+      const ignoredFeed = document.createElement("div");
+      ignoredFeed.setAttribute("data-userale-ignore", "true");
+      document.body.appendChild(ignoredFeed);
+
+      // Mutating inside an ignored container should not be counted
+      const ignoredChild = document.createElement("span");
+      ignoredFeed.appendChild(ignoredChild);
+
+      // Mutating a tracked container should be aggregated
+      const trackedModal = document.createElement("div");
+      trackedModal.id = "checkout-modal";
+      document.body.appendChild(trackedModal);
+
+      await new Promise((r) => setTimeout(r, 180));
+
+      const mutationLogs = logs.filter((l) => l.logType === "mutation");
+      expect(mutationLogs.length).toBeGreaterThanOrEqual(1);
+      expect(mutationLogs[0].type).toBe("mutation");
+      expect(
+        (mutationLogs[0].details as { addedCount: number }).addedCount,
+      ).toBe(1);
+    });
+
+    it("flushes Layer 2 rrweb ring buffer with trace context on rage click or manual flush", async () => {
+      const flushedEnvelopes: Array<ReplayEnvelope> = [];
+      let emitFrame:
+        ((event: ReplayFrame, isCheckout?: boolean) => void) | null = null;
+
+      config.update({
+        traceContext: () => ({
+          traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
+          spanId: "00f067aa0ba902b7",
+        }),
+      });
+
+      startSessionReplay(
+        {
+          observeMutations: false,
+          detectRageClicks: true,
+          recorder: ({ emit }) => {
+            emitFrame = emit;
+            return () => {};
+          },
+          onFlush: (env) => {
+            flushedEnvelopes.push(env);
+          },
+        },
+        config,
+      );
+
+      expect(getActiveReplayRingBuffer()).not.toBeNull();
+      emitFrame!({
+        type: 4,
+        data: { href: "http://localhost/" },
+        timestamp: 1_000,
+      });
+      emitFrame!({
+        type: 2,
+        data: { html: "<body></body>" },
+        timestamp: 1_005,
+      });
+      emitFrame!({ type: 3, data: { mutation: 1 }, timestamp: 1_100 });
+
+      const btn = document.createElement("button");
+      btn.id = "broken-submit";
+      document.body.appendChild(btn);
+
+      // Fire 3 rapid clicks to trigger rage_click
+      for (let i = 0; i < 3; i++) {
+        btn.dispatchEvent(
+          new window.MouseEvent("click", {
+            bubbles: true,
+            clientX: 40,
+            clientY: 40,
+          }),
+        );
+      }
+
+      // Should have emitted a rage_click log AND flushed the Layer 2 replay envelope
+      const rageLog = logs.find((l) => l.type === "rage_click");
+      expect(rageLog).toBeDefined();
+      expect(rageLog?.target).toBe("button#broken-submit");
+
+      expect(flushedEnvelopes).toHaveLength(1);
+      expect(flushedEnvelopes[0]).toMatchObject({
+        logType: "replay",
+        type: "session_replay",
+        trigger: "rage_click",
+        sessionId: "session_replay_test",
+        frameCount: 3,
+        traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
+        spanId: "00f067aa0ba902b7",
+      });
+
+      // Manual flush also works
+      const manualEnv = await flushSessionReplay("manual");
+      expect(manualEnv?.trigger).toBe("manual");
+      expect(flushedEnvelopes).toHaveLength(2);
+    });
+
+    it("bypasses keepalive when flushing replay envelopes larger than 48KB", async () => {
+      const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue({
+        ok: true,
+        status: 200,
+        statusText: "OK",
+      } as Response);
+
+      config.update({ url: "http://localhost:8000/replay" });
+
+      startSessionReplay(
+        {
+          observeMutations: false,
+          detectRageClicks: false,
+          recorder: ({ emit }) => {
+            emit({
+              type: 2,
+              data: { snapshot: "x".repeat(60_000) },
+              timestamp: 1_000,
+            });
+            return () => {};
+          },
+        },
+        config,
+      );
+
+      await flushSessionReplay("error");
+
+      expect(fetchSpy).toHaveBeenCalledTimes(1);
+      expect(fetchSpy.mock.calls[0][1]).toMatchObject({
+        method: "POST",
+        keepalive: false,
+      });
+
+      fetchSpy.mockRestore();
+    });
+  });
+});