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/12 03:38:23 UTC

(flagon) branch master updated: feat(site): modernize interactive UserALE 2.5 demo, expand Distill & deployment docs, and add Playwright E2E test (#199)

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

brucearctor pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flagon.git


The following commit(s) were added to refs/heads/master by this push:
     new b57bbef  feat(site): modernize interactive UserALE 2.5 demo, expand Distill & deployment docs, and add Playwright E2E test (#199)
b57bbef is described below

commit b57bbef9b7ab1cfe93e84b926dbc5a3476365c6c
Author: brucearctor <50...@users.noreply.github.com>
AuthorDate: Sun Oct 11 20:38:18 2026 -0700

    feat(site): modernize interactive UserALE 2.5 demo, expand Distill & deployment docs, and add Playwright E2E test (#199)
    
    * feat(site): modernize interactive UserALE 2.5 demo, expand Distill & deployment docs, and add Playwright E2E test
    
    * fix(userale): scrub input/change/keydown details to [REDACTED] on data-userale-redact elements
---
 products/userale/example/server/server.js          |   31 +
 .../packages/flagon-userale/src/attachHandlers.ts  |   39 +
 .../userale/packages/flagon-userale/src/main.ts    |   10 +
 .../test/spec/attachHandlers.spec.ts               |   26 +
 .../userale/test/spec/04-package-site-demo.spec.ts |   84 +
 site/CHANGELOG.md                                  |    4 +
 site/Dockerfile                                    |   50 +-
 site/Gemfile                                       |    1 +
 site/README.md                                     |   58 +-
 site/_docs/contributing.md                         |    1 +
 site/_docs/deployments.md                          |  110 +
 site/_docs/distill/behavioral.md                   |  165 ++
 site/_docs/distill/graphs.md                       |  107 +
 site/_docs/distill/segmentation.md                 |  116 +
 site/_docs/index.md                                |    7 +-
 site/_includes/footer.html                         |    3 +-
 site/_includes/useralejsDemo.html                  |  310 ++-
 site/_sass/_userale.scss                           |   48 +-
 site/build.sh                                      |   40 +-
 site/js/userale-demo.min.js                        | 2234 +++++++++++++++++++-
 20 files changed, 3300 insertions(+), 144 deletions(-)

diff --git a/products/userale/example/server/server.js b/products/userale/example/server/server.js
index d606ca9..67b2b73 100644
--- a/products/userale/example/server/server.js
+++ b/products/userale/example/server/server.js
@@ -78,6 +78,15 @@ app.use(
   "/build",
   express.static(path.join(__dirname, "../../packages/flagon-userale/build")),
 );
+app.use("/js", express.static(path.join(__dirname, "../../../../site/js")));
+app.use(
+  "/assets",
+  express.static(path.join(__dirname, "../../../../site/assets")),
+);
+app.use(
+  "/semantic",
+  express.static(path.join(__dirname, "../../../../site/semantic")),
+);
 app.use("/", express.static(__dirname));
 
 app.get("/", (req, res) => res.sendFile("index.html", { root: __dirname }));
@@ -87,6 +96,28 @@ app.get("/ws", (req, res) =>
 app.get("/no-logging", (req, res) =>
   res.sendFile("no-logging.html", { root: __dirname }),
 );
+app.get("/site-demo", (req, res) => {
+  const demoPath = path.join(
+    __dirname,
+    "../../../../site/_includes/useralejsDemo.html",
+  );
+  const rawHtml = fs.readFileSync(demoPath, "utf8");
+  const renderedHtml = rawHtml.replace(
+    /\{%\s*if\s+include\.inverted\s*%\}[\s\S]*?\{%\s*endif\s*%\}/g,
+    "",
+  );
+  res.type("html").send(`<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>Flagon Website Demo Test</title>
+    <link rel="stylesheet" type="text/css" href="/semantic/dist/semantic.min.css">
+    <script src="/assets/jquery.min.js"></script>
+    <script src="/semantic/dist/semantic.min.js"></script>
+  </head>
+  <body>${renderedHtml}</body>
+</html>`);
+});
 
 app.post(["/", "/v1/logs"], (req, res) => {
   const body = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
diff --git a/products/userale/packages/flagon-userale/src/attachHandlers.ts b/products/userale/packages/flagon-userale/src/attachHandlers.ts
index 6d9dc60..ad5e0f6 100644
--- a/products/userale/packages/flagon-userale/src/attachHandlers.ts
+++ b/products/userale/packages/flagon-userale/src/attachHandlers.ts
@@ -58,6 +58,25 @@ function isPasswordInput(target: EventTarget | null): boolean {
   );
 }
 
+function isRedactedElement(target: EventTarget | null): boolean {
+  if (
+    !target ||
+    typeof Element === "undefined" ||
+    !(target instanceof Element)
+  ) {
+    return false;
+  }
+  const redactSelector =
+    '[data-userale-redact]:not([data-userale-redact="false"]), [data-userale-mask]:not([data-userale-mask="false"]), .userale-redact, .userale-mask';
+  if (typeof target.closest === "function") {
+    return Boolean(target.closest(redactSelector));
+  }
+  return (
+    target.hasAttribute("data-userale-redact") ||
+    target.hasAttribute("data-userale-mask")
+  );
+}
+
 /** Maps a KeyboardEvent to an object containing useful infromation
  * @param {KeyboardEvent} e Event to extract data from
  */
@@ -65,6 +84,16 @@ export function extractKeyboardDetails(e: KeyboardEvent) {
   if (isPasswordInput(e.target)) {
     return null;
   }
+  if (isRedactedElement(e.target)) {
+    return {
+      key: "[REDACTED]",
+      code: "[REDACTED]",
+      ctrl: e.ctrlKey,
+      alt: e.altKey,
+      shift: e.shiftKey,
+      meta: e.metaKey,
+    };
+  }
   return {
     key: e.key,
     code: e.code,
@@ -83,6 +112,11 @@ export function extractInputDetails(e: InputEvent) {
   if (isPasswordInput(e.target)) {
     return null;
   }
+  if (isRedactedElement(e.target)) {
+    return {
+      value: "[REDACTED]",
+    };
+  }
   return {
     value: (e.target as HTMLInputElement | null)?.value,
   };
@@ -96,6 +130,11 @@ export function extractChangeDetails(e: Events.ChangeEvent) {
   if (isPasswordInput(e.target)) {
     return null;
   }
+  if (isRedactedElement(e.target)) {
+    return {
+      value: "[REDACTED]",
+    };
+  }
   return {
     value: e.target.value,
   };
diff --git a/products/userale/packages/flagon-userale/src/main.ts b/products/userale/packages/flagon-userale/src/main.ts
index ef042f8..48f088b 100644
--- a/products/userale/packages/flagon-userale/src/main.ts
+++ b/products/userale/packages/flagon-userale/src/main.ts
@@ -38,6 +38,12 @@ import {
   startSessionReplay,
   stopSessionReplay,
 } from "@/replay";
+import {
+  toOtlpExportLogsServiceRequest,
+  toOtlpLogRecord,
+  toProtoLogBatch,
+  toProtoUserALELog,
+} from "@/exporters";
 
 import type { Settings, Logging } from "@/types";
 
@@ -258,6 +264,10 @@ if (typeof window !== "undefined") {
     stopSessionReplay,
     flushSessionReplay,
     getTargetRect,
+    toOtlpExportLogsServiceRequest,
+    toOtlpLogRecord,
+    toProtoLogBatch,
+    toProtoUserALELog,
     addCallbacks,
     removeCallbacks,
     packageLog,
diff --git a/products/userale/packages/flagon-userale/test/spec/attachHandlers.spec.ts b/products/userale/packages/flagon-userale/test/spec/attachHandlers.spec.ts
index c9d6dfb..333829d 100644
--- a/products/userale/packages/flagon-userale/test/spec/attachHandlers.spec.ts
+++ b/products/userale/packages/flagon-userale/test/spec/attachHandlers.spec.ts
@@ -184,5 +184,31 @@ describe("attachHandlers", () => {
       Object.defineProperty(normalChangeEvt, "target", { value: textInput });
       expect(extractChangeDetails(normalChangeEvt)).toEqual({ value: "hello" });
     });
+
+    it("scrubs details to [REDACTED] on elements with data-userale-redact or data-userale-mask", () => {
+      const redactedInput = document.createElement("input");
+      redactedInput.type = "text";
+      redactedInput.setAttribute("data-userale-redact", "true");
+      redactedInput.value = "my-secret-token";
+
+      const keyEvt = new KeyboardEvent("keydown", { key: "m", code: "KeyM" });
+      Object.defineProperty(keyEvt, "target", { value: redactedInput });
+      expect(extractKeyboardDetails(keyEvt)).toEqual({
+        key: "[REDACTED]",
+        code: "[REDACTED]",
+        ctrl: false,
+        alt: false,
+        shift: false,
+        meta: false,
+      });
+
+      const inputEvt = new Event("input") as InputEvent;
+      Object.defineProperty(inputEvt, "target", { value: redactedInput });
+      expect(extractInputDetails(inputEvt)).toEqual({ value: "[REDACTED]" });
+
+      const changeEvt = new Event("change") as unknown as Events.ChangeEvent;
+      Object.defineProperty(changeEvt, "target", { value: redactedInput });
+      expect(extractChangeDetails(changeEvt)).toEqual({ value: "[REDACTED]" });
+    });
   });
 });
diff --git a/products/userale/test/spec/04-package-site-demo.spec.ts b/products/userale/test/spec/04-package-site-demo.spec.ts
new file mode 100644
index 0000000..3d1c782
--- /dev/null
+++ b/products/userale/test/spec/04-package-site-demo.spec.ts
@@ -0,0 +1,84 @@
+/*
+ * 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 { expect, test } from "@playwright/test";
+
+test.describe("Website interactive UserALE 2.5 demo (site/_includes/useralejsDemo.html)", () => {
+  test("captures interactions, switches wire formats (UserALE/OTLP/Protobuf), triggers rage-click & DOM mutation, and scrubs PII", async ({
+    page,
+  }) => {
+    await page.goto("./site-demo");
+
+    // 1. Verify UserALE 2.5 global and wire-format helpers are exposed
+    const hasHelpers = await page.evaluate(() => {
+      const u = (window as any).userale;
+      return Boolean(
+        u &&
+        typeof u.toOtlpExportLogsServiceRequest === "function" &&
+        typeof u.toProtoLogBatch === "function",
+      );
+    });
+    expect(hasHelpers).toBe(true);
+
+    // 2. Click a standard interaction button and verify a log card renders
+    await page.locator("#demo-blue-button").click();
+    await expect(page.locator("#log-feed")).toContainText(
+      "div#demo-blue-button",
+    );
+
+    // 3. Switch wire format to OpenTelemetry OTLP/JSON and verify live re-serialization
+    await page
+      .locator('#wire-format-selector button[data-format="otlp"]')
+      .click();
+    await expect(page.locator("#log-feed")).toContainText('"resourceLogs"');
+    await expect(page.locator("#log-feed")).toContainText('"flagon.target"');
+
+    // 4. Switch wire format to Protobuf v1 Canonical JSON and verify live re-serialization
+    await page
+      .locator('#wire-format-selector button[data-format="proto-json"]')
+      .click();
+    await expect(page.locator("#log-feed")).toContainText('"LOG_TYPE_');
+    await expect(page.locator("#log-feed")).toContainText('"metadata"');
+
+    // Switch back to UserALE Flat JSON
+    await page
+      .locator('#wire-format-selector button[data-format="http-json"]')
+      .click();
+
+    // 5. Trigger RageClickDetector (3 rapid clicks) and verify rage_click custom log
+    const rageBtn = page.locator("#rage-click-button");
+    await rageBtn.click({ delay: 10 });
+    await rageBtn.click({ delay: 10 });
+    await rageBtn.click({ delay: 10 });
+    await expect(page.locator("#log-feed")).toContainText("rage_click");
+
+    // 6. Trigger MutationCollector (+ Node) and verify mutation log
+    await page.locator("#mutate-dom-button").click();
+    await expect(page.locator("#log-feed")).toContainText("mutation");
+
+    // 7. Type into data-userale-redact="true" input and verify PII is scrubbed
+    const secret = "my-ultra-secret-token-999";
+    await page.locator("#demo-redact-input").fill(secret);
+    await page.locator("#demo-redact-input").dispatchEvent("change");
+    await expect(page.locator("#log-feed")).toContainText(
+      '"value": "[REDACTED]"',
+    );
+    await expect(page.locator("#log-feed")).not.toContainText(secret);
+  });
+});
diff --git a/site/CHANGELOG.md b/site/CHANGELOG.md
index 1a363f5..84597dc 100644
--- a/site/CHANGELOG.md
+++ b/site/CHANGELOG.md
@@ -2,6 +2,10 @@ CHANGELOG
 ---------
 
 ### 1.1.0 (2026-04-11)
+- Upgraded the interactive UserALE.js live demo (`_includes/useralejsDemo.html` & `js/userale-demo.min.js`) to UserALE 2.5 with a live Wire Format Switcher (`UserALE JSON`, `OTLP/JSON`, `Protobuf v1 JSON`), `RageClickDetector` trigger, `MutationCollector` DOM mutation trigger, and `data-userale-redact` PII scrubbing demo.
+- Expanded Distill documentation with dedicated pages for Frustration & Spatial Analytics (`/docs/distill/behavioral/`), Clickstream Graphs & Funnels (`/docs/distill/graphs/`), and Segmentation & Multi-Format Ingestion (`/docs/distill/segmentation/`).
+- Added a Reference Architectures & Deployments guide (`/docs/deployments/`) covering the OpenTelemetry + Grafana, OpenSearch, Apache Lakehouse, and ELK Docker Compose stacks.
+- Modernized `site/Dockerfile` (`ruby:3.3-slim`), `site/build.sh`, and `site/README.md`.
 - Updated UserALE.js and Distill documentation for 2.5 features (OpenTelemetry OTLP/JSON & SDK exporters, Protobuf v1 schemas, two-layer session replay, behavioral frustration & spatial analytics, `uv` workflow, and OpenSearch / Apache Lakehouse deployment examples).
 
 ### 1.0.2 (2025-04-03)
diff --git a/site/Dockerfile b/site/Dockerfile
index e85b3b7..e0c1a3c 100644
--- a/site/Dockerfile
+++ b/site/Dockerfile
@@ -1,30 +1,34 @@
-FROM phusion/passenger-full:3.0.7
-LABEL org.opencontainers.image.authors="poorejc@apache.org"
+# 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.
+
+FROM ruby:3.3-slim
+LABEL org.opencontainers.image.authors="dev@flagon.apache.org"
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    build-essential \
+    git \
+    && rm -rf /var/lib/apt/lists/*
 
-RUN bash -lc 'rvm list'
-RUN bash -lc 'rvm --default use ruby-3.2.5'
+WORKDIR /app
 
-# Cache bundle
-COPY Gemfile* /tmp/
-WORKDIR /tmp
+COPY Gemfile /app/Gemfile
 RUN bundle install
 
-# Install npm modules
-COPY package.json /tmp/
-WORKDIR /tmp
-RUN npm install -g
-
-# Add src code and build script
-RUN mkdir /app
-WORKDIR /app
 COPY . /app
-
-# Make the build script executable
-RUN chmod +x /app/build.sh
-
-# Run the build script
-RUN /app/build.sh
-
-WORKDIR /app/_site
+RUN chmod +x /app/build.sh && bundle exec jekyll build
 
 EXPOSE 8000
+
+CMD ["bundle", "exec", "jekyll", "serve", "--host", "0.0.0.0", "--port", "8000", "--skip-initial-build"]
diff --git a/site/Gemfile b/site/Gemfile
index 3a97c3c..7bc049f 100644
--- a/site/Gemfile
+++ b/site/Gemfile
@@ -10,6 +10,7 @@ ruby RUBY_VERSION
 # This will help ensure the proper Jekyll version is running.
 # Happy Jekylling!
 gem 'jekyll', '~> 4.3', '>= 4.3.2'
+gem 'webrick', '~> 1.8'
 
 # If you want to use GitHub Pages, remove the "gem "jekyll"" above and
 # uncomment the line below. To upgrade, run `bundle update github-pages`.
diff --git a/site/README.md b/site/README.md
index 622c695..bbc0591 100644
--- a/site/README.md
+++ b/site/README.md
@@ -1,12 +1,46 @@
-How to Build and Deploy Site
-----------------------------
-
-1. Download and Install [``Docker``](http://docker.com)
-2. Build site
-    ```
-    docker build -t flagon:site .
-    ```
-3. Deploy site on localhost:8000
-    ```
-    docker run -p 8000:8000 -it flagon:site python -m http.server
-    ```
+# Apache Flagon Website (`site/`)
+
+This directory contains the Jekyll source for the official [Apache Flagon website](https://flagon.apache.org).
+
+---
+
+## Option 1: Build and Serve with Docker (Recommended)
+
+1. Build the container image:
+   ```bash
+   docker build -t flagon:site .
+   ```
+2. Serve the site on `http://localhost:8000`:
+   ```bash
+   docker run --rm -p 8000:8000 flagon:site
+   ```
+
+---
+
+## Option 2: Local Ruby / Bundler Build
+
+Requires Ruby `>= 3.1` and Bundler:
+
+```bash
+bundle install
+bundle exec jekyll serve --port 8000
+```
+
+To build the static site output into `_site/`:
+
+```bash
+./build.sh
+```
+
+---
+
+## Updating the Interactive UserALE.js Live Demo Bundle
+
+The interactive live demo (`_includes/useralejsDemo.html`) uses the UserALE 2.5 browser IIFE bundle at `js/userale-demo.min.js`. To refresh it from the local monorepo source:
+
+```bash
+cd ../products/userale
+pnpm install
+pnpm --dir packages/flagon-userale build
+cp packages/flagon-userale/build/main.global.js ../../site/js/userale-demo.min.js
+```
diff --git a/site/_docs/contributing.md b/site/_docs/contributing.md
index 4ee9629..4afaf58 100644
--- a/site/_docs/contributing.md
+++ b/site/_docs/contributing.md
@@ -2,6 +2,7 @@
 title: Contributing
 component: flagon
 permalink: /docs/contributing/
+priority: 2
 ---
 
 First, thank you for contributing to **Apache Flagon**!
diff --git a/site/_docs/deployments.md b/site/_docs/deployments.md
new file mode 100644
index 0000000..4bb3281
--- /dev/null
+++ b/site/_docs/deployments.md
@@ -0,0 +1,110 @@
+---
+title: Reference Architectures & Deployments
+component: flagon
+permalink: /docs/deployments/
+priority: 1
+---
+
+Apache Flagon ships four production-modeled **Docker Compose reference stacks** in the [`apache/flagon`](https://github.com/apache/flagon) repository so you can evaluate and deploy end-to-end behavioral observability pipelines in minutes.
+
+---
+
+## Choosing a Reference Stack
+
+| Stack | Directory | Best For | Primary Storage & Query Engine | Visualization |
+|---|---|---|---|---|
+| **1. OpenTelemetry + Grafana** | [`products/userale/example/otel-demo/`](https://github.com/apache/flagon/tree/master/products/userale/example/otel-demo) | Cloud-native APM & full-stack trace correlation (`traceId` / `spanId` joining frontend clicks with backend spans) | Grafana Loki (Logs) + Grafana Tempo (Traces) via OTel Collector | Grafana (pre-provisioned dashboard) |
+| **2. OpenSearch** | [`examples/opensearch/`](https://github.com/apache/flagon/tree/master/examples/opensearch) | Apache-2.0 search, real-time UX friction monitoring, and Distill enrichment | OpenSearch 2.x (`userale` & `flagon-distill-analytics` indices) | OpenSearch Dashboards (7 visualizations) |
+| **3. Apache Lakehouse** | [`examples/apache-lakehouse/`](https://github.com/apache/flagon/tree/master/examples/apache-lakehouse) | Petabyte-scale event streaming, ACID table storage, and SQL/Spark behavioral data science | Apache Kafka + Apache Iceberg (`demo.flagon.userale_events` & `distill_sessions`) + Apache Spark | Apache Superset (SQL Lab + dashboard) |
+| **4. Elastic Stack (ELK)** | [`examples/elk/`](https://github.com/apache/flagon/tree/master/examples/elk) | Classic Elasticsearch + Logstash ingestion pipelines | Elasticsearch 8.15 via Logstash HTTP input | Kibana 8.15 (saved visualizations) |
+
+---
+
+## 1. OpenTelemetry Collector + Grafana + Loki + Tempo
+
+Located in [`products/userale/example/otel-demo/`](https://github.com/apache/flagon/tree/master/products/userale/example/otel-demo), this stack demonstrates zero-plugin ingestion of UserALE `OTLP/JSON` (`ExportLogsServiceRequest`) alongside W3C TraceContext (`traceparent`) propagation.
+
+### Architecture
+```text
+Browser (UserALE 2.5 OtlpJsonExporter + W3C traceparent)
+   ├── POST /v1/logs ────► OpenTelemetry Collector ──► Grafana Loki (flagon.* attributes + replay frames)
+   └── GET /api/checkout ─► Demo Checkout API ───────► OpenTelemetry Collector ──► Grafana Tempo (distributed traces)
+                                                                                          │
+                                                                                          ▼
+                                                                             Grafana Unified Dashboard
+```
+
+### Quick Start
+```bash
+cd products/userale/example/otel-demo
+docker compose up --build
+```
+- **Interactive Demo Storefront**: `http://localhost:8000`
+- **Grafana Dashboard**: `http://localhost:3000` (anonymous Viewer enabled; pre-provisioned under *Dashboards -> Apache Flagon - UserALE & OpenTelemetry Correlated Observability*)
+
+---
+
+## 2. OpenSearch & OpenSearch Dashboards + Distill
+
+Located in [`examples/opensearch/`](https://github.com/apache/flagon/tree/master/examples/opensearch), this 100% Apache-2.0 stack ingests UserALE flat JSON, Protobuf v1 `LogBatch`, or OTLP/JSON logs into OpenSearch and runs a continuous **Distill Analytics Worker** to compute session summaries, composite UX frustration scores, rage/dead-click counts, and scroll depth metrics.
+
+### Architecture
+```text
+Browser (UserALE 2.5) ──► Flagon Ingest Bridge (:8000) ──► OpenSearch (`userale` index)
+                                                                │
+                                                                ▼
+OpenSearch Dashboards (:5601) ◄── (`flagon-distill-analytics`) ◄── Distill Analytics Worker
+```
+
+### Quick Start
+```bash
+cd examples/opensearch
+docker compose up --build
+```
+- **Demo Storefront & Ingest Bridge**: `http://localhost:8000`
+- **OpenSearch Dashboards**: `http://localhost:5601` (`Apache Flagon — UserALE & Distill Behavioral Observability`)
+
+---
+
+## 3. All-Apache Behavioral Lakehouse (Kafka + Iceberg + Spark + Superset)
+
+Located in [`examples/apache-lakehouse/`](https://github.com/apache/flagon/tree/master/examples/apache-lakehouse), this stack streams UserALE telemetry through **Apache Kafka (KRaft)** into **Apache Iceberg** tables stored on MinIO S3, enriches sessions with **Apache Flagon Distill**, provides interactive **Apache Spark SQL / PySpark** notebooks, and visualizes metrics in **Apache Superset**.
+
+### Architecture
+```text
+Browser (UserALE 2.5) ──► Kafka Ingest Gateway (:8000) ──► Apache Kafka (`flagon.userale.raw`)
+                                                                  │
+                                                                  ▼
+                                                    Distill Lakehouse Consumer
+                                                                  │
+                                    ┌─────────────────────────────┴─────────────────────────────┐
+                                    ▼                                                           ▼
+               Iceberg: `demo.flagon.userale_events`                       Iceberg: `demo.flagon.distill_sessions`
+                                    │                                                           │
+                                    └─────────────────────────────┬─────────────────────────────┘
+                                                                  ▼
+                                          Apache Spark (:8888) & Apache Superset (:8088)
+```
+
+### Quick Start
+```bash
+cd examples/apache-lakehouse
+docker compose up --build
+```
+- **Demo Storefront & Kafka Gateway**: `http://localhost:8000`
+- **Apache Superset**: `http://localhost:8088` (`admin` / `admin`)
+- **Apache Spark + Iceberg JupyterLab**: `http://localhost:8888`
+
+---
+
+## 4. Elastic Stack (Elasticsearch + Logstash + Kibana)
+
+Located in [`examples/elk/`](https://github.com/apache/flagon/tree/master/examples/elk), this stack pairs Logstash HTTP ingestion (`:8100`) with Elasticsearch 8.15 and Kibana 8.15.
+
+### Quick Start
+```bash
+cd examples/elk
+docker compose up
+```
+- **Logstash UserALE Endpoint**: `http://localhost:8100`
+- **Kibana**: `http://localhost:5601` (import `examples/elk/kibana/Apache Flagon Corso Dashboard.ndjson` under *Stack Management -> Saved Objects*)
diff --git a/site/_docs/distill/behavioral.md b/site/_docs/distill/behavioral.md
new file mode 100644
index 0000000..63f9337
--- /dev/null
+++ b/site/_docs/distill/behavioral.md
@@ -0,0 +1,165 @@
+---
+title: Frustration & Spatial Analytics
+component: distill
+permalink: /docs/distill/behavioral/
+priority: 1
+---
+
+**Apache Flagon Distill 2.5+** includes dedicated behavioral analytics modules (`distill.analytics.frustration`, `distill.analytics.spatial`, and `distill.analytics.otel`) for quantifying UX friction, mapping spatial click/scroll density, and correlating frontend user telemetry with distributed OpenTelemetry traces.
+
+All behavioral analytics functions accept:
+- A list of UserALE flat JSON dictionaries,
+- A Protobuf v1 `LogBatch` dictionary (`{"logs": [...]}`),
+- An OpenTelemetry OTLP/JSON `ExportLogsServiceRequest` dictionary (`{"resourceLogs": [...]}`), or
+- A Distill UID-keyed log dictionary (as returned by `distill.setup(...)`).
+
+---
+
+## UX Frustration Analytics (`distill.analytics.frustration`)
+
+### 1. Rage-Click Detection (`detect_rage_clicks`)
+
+Detects rapid, repeated clicks on the same DOM target within a short time and spatial window. Supports both:
+- **Explicit `rage_click` custom logs** emitted in real time by UserALE 2.5's `RageClickDetector` (`source: "explicit"`), and
+- **Retroactive detection** across raw `click` event streams (`source: "inferred"`).
+
+```python
+import distill
+
+incidents = distill.detect_rage_clicks(
+    logs,
+    window_ms=1000,   # Sliding window in milliseconds (default: 1000)
+    threshold=3,      # Minimum clicks within window_ms (default: 3)
+    radius_px=30.0,   # Maximum Euclidean distance in pixels (default: 30.0)
+)
+```
+
+Each returned incident dictionary contains:
+```python
+{
+    "target": "button#checkout-submit",
+    "pageUrl": "https://shop.example.com/cart",
+    "sessionId": "session_1712000000",
+    "timestamp": 1712000045120,
+    "clickCount": 4,
+    "targetRect": {"x": 420, "y": 510, "width": 160, "height": 44},
+    "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
+    "source": "explicit",  # or "inferred"
+}
+```
+
+### 2. Dead-Click Detection (`detect_dead_clicks`)
+
+Identifies clicks on elements that produce **no observable application response** within `response_window_ms` — i.e., no subsequent DOM mutation (`logType == "mutation"`), no form/input event (`input`, `change`, `submit`), and no navigation (`load` or `pageUrl` change).
+
+```python
+dead_clicks = distill.detect_dead_clicks(
+    logs,
+    response_window_ms=1000,
+)
+```
+
+### 3. DOM Mutation Thrash Detection (`detect_dom_thrash`)
+
+Analyzes UserALE `mutation` logs emitted by `MutationCollector` to identify high-churn layout bursts (`addedCount + removedCount + attributeCount >= churn_threshold`) and rank DOM targets by cumulative structural churn.
+
+```python
+thrash = distill.detect_dom_thrash(logs, churn_threshold=20)
+print(thrash["totalChurn"], thrash["burstCount"])
+print(thrash["targetChurn"])  # {"div#product-grid": 64, "ul#search-results": 22}
+```
+
+### 4. Composite UX Frustration Score (`frustration_score`)
+
+Computes a normalized `[0.0, 100.0]` UX friction score and severity rating (`"none"`, `"low"`, `"medium"`, `"high"`) combining rage clicks, dead clicks, DOM thrash bursts, and runtime JS errors (`error` / `unhandledrejection`):
+
+```python
+report = distill.frustration_score(logs)
+# {
+#     "frustrationScore": 65.0,
+#     "severity": "high",
+#     "rageClickCount": 2,
+#     "deadClickCount": 1,
+#     "domThrashBurstCount": 1,
+#     "errorCount": 0,
+#     "rageClicks": [...],
+#     "deadClicks": [...],
+# }
+```
+
+---
+
+## Spatial & Viewport Analytics (`distill.analytics.spatial`)
+
+### 1. Viewport-Normalized Click Coordinates (`click_heatmap_data`)
+
+Extracts raw `(x, y)` click coordinates alongside viewport-normalized coordinates (`normX`, `normY` in `[0.0, 1.0]` using `scrnRes`) and element-relative coordinates (`relX`, `relY` in `[0.0, 1.0]` when `targetRect` bounding boxes are present):
+
+```python
+points = distill.click_heatmap_data(logs, normalize_viewport=True)
+# [
+#   {
+#     "target": "button#buy-now",
+#     "pageUrl": "https://shop.example.com/item/42",
+#     "type": "click",
+#     "x": 640.0, "y": 360.0,
+#     "normX": 0.5, "normY": 0.5,
+#     "relX": 0.42, "relY": 0.55,
+#     "targetRect": {"x": 580, "y": 340, "width": 140, "height": 36}
+#   }
+# ]
+```
+
+### 2. Interactive 2D Click Density Heatmaps (`plot_click_heatmap`)
+
+Generates a Plotly `Figure` (`go.Histogram2d` with top-down screen y-axis orientation) for rendering in Jupyter notebooks or web dashboards:
+
+```python
+fig = distill.plot_click_heatmap(
+    logs,
+    nbinsx=25,
+    nbinsy=25,
+    normalize_viewport=True,
+)
+fig.show()
+```
+
+### 3. Scroll Depth Summary (`scroll_depth_summary`)
+
+Aggregates `scroll` and `wheel` telemetry per `pageUrl` to calculate maximum vertical/horizontal scroll reach and how many viewport heights (`maxViewportMultiple`) users scrolled down each page:
+
+```python
+scroll_metrics = distill.scroll_depth_summary(logs)
+# {
+#   "https://shop.example.com/catalog": {
+#     "maxScrollX": 0.0,
+#     "maxScrollY": 2160.0,
+#     "scrollEventCount": 18,
+#     "maxViewportMultiple": 3.0
+#   }
+# }
+```
+
+---
+
+## OpenTelemetry Trace & Replay Correlation (`distill.analytics.otel`)
+
+### 1. Correlate Frontend Logs by W3C `traceId` (`correlate_by_trace`)
+
+Groups all frontend UserALE interactions, rage clicks, DOM mutations, and session replay envelopes by their W3C TraceContext `traceId` (`flagon.trace_id` in OTLP):
+
+```python
+traces = distill.correlate_by_trace(otlp_or_userale_payload)
+for trace_id, info in traces.items():
+    print(trace_id, info["targets"], info["rageClickCount"], info["spanIds"])
+```
+
+### 2. Extract Session Replay Envelopes (`extract_replay_envelopes`)
+
+Extracts Layer 2 `rrweb`-compatible DOM replay envelopes (`logType == "replay"`) triggered by rage clicks, uncaught exceptions, or manual flushes:
+
+```python
+envelopes = distill.extract_replay_envelopes(logs)
+for env in envelopes:
+    print(env["trigger"], env["sessionId"], env["frameCount"], env["traceId"])
+```
diff --git a/site/_docs/distill/graphs.md b/site/_docs/distill/graphs.md
new file mode 100644
index 0000000..cb780f5
--- /dev/null
+++ b/site/_docs/distill/graphs.md
@@ -0,0 +1,107 @@
+---
+title: Clickstream Graphs & Funnels
+component: distill
+permalink: /docs/distill/graphs/
+priority: 2
+---
+
+**Apache Flagon Distill** models user clickstreams as directed behavioral graphs using [NetworkX](https://networkx.org/) and renders interactive flow visualizations with [Plotly](https://plotly.com/python/).
+
+---
+
+## Preparing Clickstream Edges
+
+Given a list of UserALE logs or a sorted log dictionary from `distill.setup(...)`, extract sequential `(source_target, destination_target)` interaction pairs or pass the logs directly to `distill.transition_matrix(...)`:
+
+```python
+import distill
+import networkx as nx
+
+sorted_logs = distill.setup("userale_logs.json", date_type="datetime")
+targets = [log["target"] for log in sorted_logs.values() if log.get("target")]
+edges = list(nx.utils.pairwise(targets))
+```
+
+---
+
+## Directed Behavioral Graphs (`createDiGraph`)
+
+Builds a weighted `networkx.DiGraph` where each node is a DOM target (or mapped label) and each edge `(u, v)` carries a `capacity` attribute equal to the number of observed transitions from `u` to `v`.
+
+```python
+G = distill.createDiGraph(
+    nodes=targets,
+    edges=edges,
+    drop_recursions=True,  # Exclude self-loops (target -> same target)
+    node_labels={
+        "button#add-to-cart": "Add to Cart",
+        "a#checkout-link": "Checkout",
+        "button#pay-now": "Submit Order",
+    },
+)
+
+for u, v, data in G.edges(data=True):
+    print(f"{u} -> {v}: {data['capacity']} transitions")
+```
+
+---
+
+## First-Order Markov Transition Matrix (`transition_matrix`)
+
+Computes the state-transition probability matrix $P(\text{target}_{t+1} \mid \text{target}_t)$ directly from either a list/dict of UserALE logs or a list of `(source, target)` edge tuples.
+
+```python
+# Returns a row-normalized pandas.DataFrame by default:
+df_prob = distill.transition_matrix(
+    sorted_logs,
+    drop_recursions=True,
+    as_dataframe=True,
+)
+print(df_prob)
+
+# Or return a nested Python dictionary:
+prob_dict = distill.transition_matrix(
+    sorted_logs,
+    drop_recursions=True,
+    as_dataframe=False,
+)
+```
+
+---
+
+## Interactive Sankey Diagrams (`sankey`)
+
+Generates a Plotly Sankey diagram (`go.Figure`) visualizing multi-step user flow volume across DOM targets:
+
+```python
+fig = distill.sankey(
+    edges,
+    node_labels={
+        "button#search": "Search Catalog",
+        "div.product-card": "View Product",
+        "button#add-to-cart": "Add to Cart",
+        "button#checkout": "Checkout",
+    },
+    drop_recursions=True,
+)
+fig.show()
+```
+
+---
+
+## Conversion Funnels (`funnel`)
+
+Computes conversion progression and drop-off across an ordered sequence of target milestones. When `infer=True` (default), Distill automatically discovers the maximum-capacity path between non-adjacent target milestones in the user clickstream graph:
+
+```python
+fig = distill.funnel(
+    edges,
+    targets=[
+        "button#search",
+        "button#add-to-cart",
+        "button#checkout",
+    ],
+    infer=True,
+)
+fig.show()
+```
diff --git a/site/_docs/distill/segmentation.md b/site/_docs/distill/segmentation.md
new file mode 100644
index 0000000..26c04ef
--- /dev/null
+++ b/site/_docs/distill/segmentation.md
@@ -0,0 +1,116 @@
+---
+title: Segmentation & Multi-Format Ingestion
+component: distill
+permalink: /docs/distill/segmentation/
+priority: 3
+---
+
+**Apache Flagon Distill** provides schema validation, multi-format normalization (`UserALE JSON`, `Protobuf v1 JSON`, and `OpenTelemetry OTLP/JSON`), session summarization, and time/event-based log segmentation.
+
+---
+
+## Multi-Format Ingestion (`distill.schemas.userale`)
+
+UserALE 2.5+ can emit telemetry in three wire formats:
+1. **UserALE Flat JSON (`v2-json`)**: Array of flat JSON objects (`raw`, `interval`, `custom`, `mutation`, `replay`).
+2. **Protobuf v1 Canonical JSON (`proto-json`)**: `apache.flagon.userale.v1.LogBatch` (`{"logs": [...]}`).
+3. **OpenTelemetry OTLP/JSON (`otlp-json`)**: `ExportLogsServiceRequest` (`{"resourceLogs": [...]}`).
+
+Distill's `extract_logs(payload)` and `normalize_userale_dict(record)` automatically normalize any of these three wire formats into canonical UserALE dictionaries validated by `UserAleSchema`:
+
+```python
+import distill
+from distill import Log
+from distill.core.types import UserAleSchema
+from distill.schemas.userale import extract_logs
+
+# Works seamlessly with flat UserALE JSON lists, Proto-JSON LogBatch dicts,
+# or OTLP/JSON ExportLogsServiceRequest dicts:
+logs = extract_logs(wire_payload)
+
+# Validate with Pydantic v2:
+validated = [UserAleSchema.validate_python(entry) for entry in logs]
+
+# Or load into a Distill Log container:
+log_container = Log(data=logs[0], schema=UserAleSchema)
+```
+
+---
+
+## Session Summary & Click Rate (`distill.analytics.stat`)
+
+### `session_summary`
+
+Aggregates a log stream into per-session behavioral metrics (`durationSeconds`, `eventCount`, `uniqueTargets`, `pageUrls`, `eventTypeCounts`, and `isBounce`):
+
+```python
+summary = distill.session_summary(logs)
+print(summary["totalSessions"], summary["totalEvents"], summary["bounceRate"])
+for session_id, metrics in summary["sessions"].items():
+    print(session_id, metrics["durationSeconds"], metrics["uniqueTargets"])
+```
+
+### `click_rate`
+
+Computes overall session click rate (`clicks_per_second`, `total_session_seconds`, `total_clicks`):
+
+```python
+rate, duration_sec, clicks = distill.click_rate(logs)
+```
+
+---
+
+## Session & Window Segmentation (`distill.segmentation`)
+
+Distill's `Segment` and `Segments` classes slice a sorted dictionary of logs (produced by `distill.setup(...)`) into named behavioral windows that support set algebra (`union`, `intersection`, `difference`).
+
+### 1. Creating Explicit & Rule-Based Segments
+
+```python
+import distill
+
+sorted_logs = distill.setup("userale_logs.json", date_type="datetime")
+
+# Match logs around specific field values (with pre/post time windows in seconds):
+checkout_segments = distill.generate_segments(
+    sorted_logs,
+    field_name="target",
+    field_values=["button#checkout"],
+    start_window=5,
+    end_window=15,
+)
+
+# Fixed-duration sliding windows (e.g., 60-second windows):
+fixed_windows = distill.generate_fixed_time_segments(
+    sorted_logs,
+    interval=60,
+     label="minute_window",
+)
+
+# Collapsing windows where every log inside the segment satisfies a predicate:
+click_bursts = distill.generate_collapsing_window_segments(
+    sorted_logs,
+    func_Of_Interst=lambda log: log.get("type") == "click",
+)
+
+# Detect idle deadspace windows (>= threshold seconds between user events):
+idle_periods = distill.detect_deadspace(
+    sorted_logs,
+    deadspace_threshold=30,
+)
+```
+
+### 2. Set Operations & Exporting Segments
+
+```python
+# Combine or filter segments using set logic:
+active_window = distill.difference(
+    fixed_windows[0],
+    idle_periods[0],
+    sorted_logs,
+)
+
+# Export segmented logs to a list of dictionaries or CSV file:
+records = distill.write_segment(active_window, sorted_logs)
+distill.export_segments("./checkout_segments.csv", checkout_segments, sorted_logs)
+```
diff --git a/site/_docs/index.md b/site/_docs/index.md
index 7f65fc2..ba7dbfe 100644
--- a/site/_docs/index.md
+++ b/site/_docs/index.md
@@ -5,4 +5,9 @@ permalink: /docs/
 priority: 0
 ---
 
-The Apache Flagon docs are organized by product.  Each product's documentation includes information like quick start guides, tutorials, full API descriptions, and contribution guides.  
+The **Apache Flagon** documentation is organized by component and covers client-side telemetry collection, behavioral analytics, and full-stack observability deployments:
+
+- **[Reference Architectures & Deployments]({{ '/docs/deployments' | prepend: site.baseurl }})**: Turnkey Docker Compose stacks for OpenTelemetry + Grafana (`Loki` + `Tempo`), OpenSearch + Dashboards, Apache Lakehouse (`Kafka` + `Iceberg` + `Spark` + `Superset`), and Elastic Stack (`ELK`).
+- **[Apache Flagon UserALE.js]({{ '/docs/useralejs' | prepend: site.baseurl }})**: Frontend behavioral telemetry, OpenTelemetry `OTLP/JSON` & `Protobuf v1` exporters, W3C TraceContext correlation, DOM mutation & rage-click detection, and two-layer session replay (`rrweb`).
+- **[Apache Flagon Distill]({{ '/docs/distill' | prepend: site.baseurl }})**: Python behavioral analytics framework for multi-format UserALE schema validation, UX frustration & spatial analytics, clickstream graphs, Markov transition matrices, and session segmentation.
+- **[Contributing Guide]({{ '/docs/contributing' | prepend: site.baseurl }})**: Monorepo setup with Nix, `pnpm`, `uv`, and `buf`.
diff --git a/site/_includes/footer.html b/site/_includes/footer.html
index d724976..1a0d5c2 100644
--- a/site/_includes/footer.html
+++ b/site/_includes/footer.html
@@ -11,8 +11,9 @@
       <div class="column">
         <h4 class="ui inverted header">Docs</h4>
         <div class="ui inverted link list">
-          <a class="item" href="{{ '/docs/useralejs' | prepend: site.baseurl }}">UserAle.js</a>
+          <a class="item" href="{{ '/docs/useralejs' | prepend: site.baseurl }}">UserALE.js</a>
           <a class="item" href="{{ '/docs/distill' | prepend: site.baseurl }}">Distill</a>
+          <a class="item" href="{{ '/docs/deployments' | prepend: site.baseurl }}">Reference Deployments</a>
         </div>
       </div>
       <div class="column">
diff --git a/site/_includes/useralejsDemo.html b/site/_includes/useralejsDemo.html
index e3b2b25..859d966 100644
--- a/site/_includes/useralejsDemo.html
+++ b/site/_includes/useralejsDemo.html
@@ -1,134 +1,274 @@
 <div class="ui {% if include.inverted %}inverted grey{% endif %} very padded vertical segment">
   <div class="ui center aligned text container">
-    <h2 class="ui {% if include.inverted %}inverted{% endif %} header">UserALE.js</h2>
+    <h2 class="ui {% if include.inverted %}inverted{% endif %} header">Interactive UserALE.js 2.5 Live Telemetry Demo</h2>
     <p>
-      UserALE.js is the DOM/JavaScript client for UserALE.  Every user activity is logged and sent to the UserALE backend for storage and analysis by Distill.
+      Interact with the controls below to inspect live telemetry generated in your browser by <strong>UserALE.js 2.5</strong>. Switch between <strong>UserALE Flat JSON</strong>, <strong>OpenTelemetry (OTLP/JSON)</strong>, and <strong>Protobuf v1 (<code>LogBatch</code>)</strong> wire formats in real time.
     </p>
-    <div class="ui basic vertical segment">
-      <div id="start-button" class="ui green button">Start Logging</div>
-      <div id="stop-button" class="ui red button">Pause Logging</div>
+    <div class="ui basic vertical segment" data-userale-ignore="true">
+      <div id="start-button" class="ui green button"><i class="play icon"></i>Start Logging</div>
+      <div id="stop-button" class="ui red button"><i class="pause icon"></i>Pause Logging</div>
+      <div id="clear-button" class="ui basic {% if include.inverted %}inverted{% endif %} button"><i class="trash alternate outline icon"></i>Clear Feed</div>
     </div>
   </div>
 
   <div class="ui container">
-    <div class="ui equal height two column grid">
-      <div class="center aligned column">
-        <div class="ui basic row segment">
-          <h4 class="ui dividing header">Buttons</h4>
-          <div class="ui blue button">Blue</div>
-          <div class="ui yellow button">Yellow</div>
-          <div class="ui green button">Green</div>
-          <div class="ui red button">Red</div>
+    <div class="ui stackable two column grid">
+      <div class="eight wide column">
+        <div class="ui basic vertical segment">
+          <h4 class="ui dividing {% if include.inverted %}inverted{% endif %} header">1. Standard UI Interactions</h4>
+          <div id="demo-blue-button" class="ui blue button">Blue</div>
+          <div id="demo-yellow-button" class="ui yellow button">Yellow</div>
+          <div id="demo-green-button" class="ui green button">Green</div>
+          <div id="demo-red-button" class="ui red button">Red</div>
         </div>
 
-        <div class="ui form">
-          <h4 class="ui dividing header">Checkboxes</h4>
-          <div class="field">
-            <div class="two fields">
-              <div class="field">
-                <input type="checkbox">
-                <label>Checked!</label>
-              </div>
-              <div class="field">
-                <div class="two fields">
-                  <div class="field">
-                    <div class="ui radio checkbox">
-                      <input type="radio" name="demo-checkbox">
-                      <label>Yes</label>
-                    </div>
-                  </div>
-                  <div class="field">
-                    <div class="ui radio checkbox">
-                      <input type="radio" name="demo-checkbox">
-                      <label>No</label>
-                    </div>
-                  </div>
-                </div>
-              </div>
+        <div class="ui basic vertical segment">
+          <h4 class="ui dividing {% if include.inverted %}inverted{% endif %} header">2. Behavioral &amp; Session Replay Signals (2.5)</h4>
+          <p style="font-size: 0.92em; opacity: 0.85;">
+            Rapidly click the button below 3+ times to trigger <code>RageClickDetector</code> (<code>rage_click</code> + normalized <code>targetRect</code> bounding box), or mutate the DOM to emit a debounced <code>mutation</code> summary log.
+          </p>
+          <button id="rage-click-button" type="button" class="ui orange button">
+            <i class="bolt icon"></i>Click 3x Rapidly (Rage Click)
+          </button>
+          <button id="mutate-dom-button" type="button" class="ui teal button">
+            <i class="plus square outline icon"></i>Mutate DOM (+ Node)
+          </button>
+          <div id="mutation-sandbox" style="margin-top: 0.75em;"></div>
+        </div>
+
+        <div class="ui {% if include.inverted %}inverted{% endif %} form">
+          <h4 class="ui dividing {% if include.inverted %}inverted{% endif %} header">3. Forms &amp; Privacy Controls (<code>data-userale-redact</code>)</h4>
+          <div class="two fields">
+            <div class="field">
+              <label>Standard Text Input</label>
+              <input id="demo-standard-input" type="text" placeholder="Type to log input details...">
+            </div>
+            <div class="field">
+              <label>PII-Redacted Input (<code>data-userale-redact</code>)</label>
+              <input id="demo-redact-input" type="text" data-userale-redact="true" placeholder="Sensitive input is scrubbed to [REDACTED]">
             </div>
           </div>
 
-          <h4 class="ui dividing header">Inputs</h4>
-          <div class="field">
-            <div class="two fields">
-              <div class="field">
-                <div class="ui labeled input">
-                  <div class="ui grey label">Text</div>
-                  <input type="text">
+          <div class="two fields">
+            <div class="field">
+              <label>Checkboxes &amp; Radios</label>
+              <div class="inline fields" style="margin-top: 0.5em;">
+                <div class="field">
+                  <div class="ui checkbox">
+                    <input type="checkbox">
+                    <label>Opt-in</label>
+                  </div>
+                </div>
+                <div class="field">
+                  <div class="ui radio checkbox">
+                    <input type="radio" name="demo-checkbox">
+                    <label>Yes</label>
+                  </div>
+                </div>
+                <div class="field">
+                  <div class="ui radio checkbox">
+                    <input type="radio" name="demo-checkbox">
+                    <label>No</label>
+                  </div>
                 </div>
               </div>
+            </div>
 
-              <div class="field">
-                <div id="demo-dropdown" class="ui fluid selection dropdown">
-                  <input type="hidden" name="flagon-component">
-                  <i class="dropdown icon"></i>
-                  <div class="default text">Select Component</div>
-                  <div class="menu">
-                    <div class="item">UserALE</div>
-                    <div class="item">Distill</div>
-                    <div class="item">Tap</div>
-                    <div class="item">Stout</div>
-                  </div>
+            <div class="field">
+              <label>Flagon Component</label>
+              <div id="demo-dropdown" class="ui fluid selection dropdown">
+                <input type="hidden" name="flagon-component">
+                <i class="dropdown icon"></i>
+                <div class="default text">Select Component</div>
+                <div class="menu">
+                  <div class="item" data-value="userale">UserALE.js 2.5</div>
+                  <div class="item" data-value="userale-ext">UserALE Browser Extension (MV3)</div>
+                  <div class="item" data-value="distill">Distill 2.5 (Python Analytics)</div>
+                  <div class="item" data-value="otel">OpenTelemetry + Grafana Stack</div>
+                  <div class="item" data-value="opensearch">OpenSearch + Data Prepper Stack</div>
+                  <div class="item" data-value="lakehouse">Apache Iceberg Lakehouse Stack</div>
                 </div>
               </div>
             </div>
           </div>
 
-          <h4 class="ui dividing header">Maps</h4>
-          <div class="ui basic row segment">
+          <h4 class="ui dividing {% if include.inverted %}inverted{% endif %} header">4. Spatial Viewport &amp; Map Interaction</h4>
+          <div class="ui basic row segment" style="padding: 0;">
             <div id="map-example"></div>
           </div>
-
-
         </div>
       </div>
 
-      <div id="log-feed" class="center aligned column">
-        <div class="ui fluid row segment">
-
+      <div class="eight wide column" data-userale-ignore="true">
+        <div class="ui secondary Stackable menu" style="margin-bottom: 0.5em;">
+          <div class="header item {% if include.inverted %} inverted{% endif %}" style="padding-left: 0;">Wire Format:</div>
+          <div class="ui small buttons" id="wire-format-selector">
+            <button type="button" class="ui active blue button" data-format="http-json">UserALE JSON</button>
+            <button type="button" class="ui button" data-format="otlp">OTLP/JSON (OpenTelemetry)</button>
+            <button type="button" class="ui button" data-format="proto-json">Protobuf v1 JSON</button>
+          </div>
         </div>
+        <div id="log-feed" data-userale-ignore="true"></div>
       </div>
     </div>
   </div>
 </div>
 
-
 <link rel="stylesheet" href="/assets/leaflet/leaflet.css" />
 <script src="/assets/leaflet/leaflet.js"></script>
-<script src="/js/userale-demo.min.js" data-url=""></script>
+<script
+  src="/js/userale-demo.min.js"
+  data-autostart="true"
+  data-log-details="true"
+  data-tool="flagon-website-demo"
+  data-version="2.5.0"
+></script>
 
 <script>
   $(document).ready(function () {
+    var selectedFormat = 'http-json';
+    var recentLogs = [];
+    var mutationCount = 0;
+
     $('#demo-dropdown').dropdown();
     $('.ui.checkbox').checkbox();
 
-    $('#start-button').on('click', userale.start);
-    $('#stop-button').on('click', userale.stop);
-    userale.options({
-      logCb: addLog,
-      noSend: true
-    });
+    // Register a no-op exporter so the interactive demo never makes network requests
+    if (window.userale && typeof userale.registerExporter === 'function') {
+      userale.registerExporter({
+        export: function () {
+          return Promise.resolve({ success: true, statusCode: 200 });
+        }
+      });
+    }
 
-    var map = L.map('map-example').setView([42.3601, -71.0589], 13);
-    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
-      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
-    }).addTo(map);
+    // Enable W3C trace context & two-layer session replay / DOM mutation observation
+    if (window.userale && typeof userale.options === 'function') {
+      userale.options({
+        toolName: 'flagon-website-demo',
+        toolVersion: '2.5.0',
+        logDetails: true,
+        observeMutations: true,
+        traceContext: function () {
+          return {
+            traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
+            spanId: '00f067aa0ba902b7'
+          };
+        }
+      });
+    }
+
+    if (window.userale && typeof userale.startSessionReplay === 'function') {
+      userale.startSessionReplay({
+        observeMutations: true,
+        detectRageClicks: true
+      });
+    }
+
+    function formatPayload(log) {
+      var cfg = window.userale && typeof userale.options === 'function' ? userale.options() : {};
+      if (selectedFormat === 'otlp' && window.userale && typeof userale.toOtlpExportLogsServiceRequest === 'function') {
+        return userale.toOtlpExportLogsServiceRequest([log], cfg);
+      }
+      if (selectedFormat === 'proto-json' && window.userale && typeof userale.toProtoLogBatch === 'function') {
+        return userale.toProtoLogBatch([log], cfg);
+      }
+      return log;
+    }
+
+    function escapeHtml(str) {
+      return String(str)
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;');
+    }
+
+    function renderFeed() {
+      var $feed = $('#log-feed');
+      $feed.empty();
+      for (var i = 0; i < recentLogs.length; i++) {
+        var entry = recentLogs[i];
+        var badgeColor = 'blue';
+        if (entry.type === 'rage_click' || entry.logType === 'replay') badgeColor = 'orange';
+        else if (entry.logType === 'mutation') badgeColor = 'teal';
+        else if (entry.logType === 'custom') badgeColor = 'purple';
 
-    function addLog(log) {
-      var html = '<div class="log ui fluid row segment">' + JSON.stringify(log) + '</div>';
-      $('#log-feed').prepend(html);
+        var payloadJson = JSON.stringify(formatPayload(entry), null, 2);
+        var cardHtml =
+          '<div class="log-card">' +
+            '<div class="log-card-header">' +
+              '<span class="ui mini ' + badgeColor + ' label">' + escapeHtml(entry.logType || 'raw') + '</span> ' +
+              '<span class="ui mini basic label">' + escapeHtml(entry.type || 'event') + '</span> ' +
+              '<code class="log-target">' + escapeHtml(entry.target || 'document') + '</code>' +
+            '</div>' +
+            '<pre class="log-json">' + escapeHtml(payloadJson) + '</pre>' +
+          '</div>';
+        $feed.append(cardHtml);
+      }
     }
 
-    function clearLogs() {
-      setTimeout(function () {
-        var $feed = $('#log-feed .log');
-        if ($feed.length > 10) {
-          $feed.slice(9).remove();
+    if (window.userale && typeof userale.addCallbacks === 'function') {
+      userale.addCallbacks({
+        websiteDemoFeed: function (log) {
+          if (!log) return log;
+          // Skip high-frequency hover noise in the visual demo feed
+          if (log.type === 'mouseover') {
+            return log;
+          }
+          var targetStr = String(log.target || '');
+          var pathStr = Array.isArray(log.path) ? log.path.join(' ') : '';
+          // Ignore interactions inside the log feed panel, wire format switcher, or start/stop/clear controls
+          if (
+            targetStr.indexOf('log-') !== -1 ||
+            pathStr.indexOf('log-feed') !== -1 ||
+            pathStr.indexOf('wire-format-selector') !== -1 ||
+            targetStr.indexOf('start-button') !== -1 ||
+            targetStr.indexOf('stop-button') !== -1 ||
+            targetStr.indexOf('clear-button') !== -1
+          ) {
+            return false;
+          }
+          recentLogs.unshift(Object.assign({}, log));
+          if (recentLogs.length > 8) {
+            recentLogs.pop();
+          }
+          renderFeed();
+          return log;
         }
-        clearLogs();
-      }, 1000);
+      });
     }
 
-    clearLogs();
+    $('#start-button').on('click', function () {
+      if (window.userale) userale.start();
+    });
+    $('#stop-button').on('click', function () {
+      if (window.userale) userale.stop();
+    });
+    $('#clear-button').on('click', function () {
+      recentLogs = [];
+      renderFeed();
+    });
+
+    $('#mutate-dom-button').on('click', function () {
+      mutationCount += 1;
+      var $chip = $(
+        '<span class="ui tiny teal label" style="margin: 2px;">' +
+          'Dynamic Node #' + mutationCount +
+        '</span>'
+      );
+      $('#mutation-sandbox').append($chip);
+    });
+
+    $('#wire-format-selector button').on('click', function () {
+      $('#wire-format-selector button').removeClass('active blue');
+      $(this).addClass('active blue');
+      selectedFormat = $(this).data('format');
+      renderFeed();
+    });
+
+    var map = L.map('map-example').setView([42.3601, -71.0589], 13);
+    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+    }).addTo(map);
   });
 </script>
diff --git a/site/_sass/_userale.scss b/site/_sass/_userale.scss
index 7ce19f2..d8148dd 100644
--- a/site/_sass/_userale.scss
+++ b/site/_sass/_userale.scss
@@ -1,12 +1,54 @@
 #log-feed {
-  height: 720px;
-  overflow-y: hidden;
+  height: 680px;
+  overflow-y: auto;
+  background: #1b1c1d;
+  border-radius: 6px;
+  padding: 0.75rem;
+  border: 1px solid rgba(255, 255, 255, 0.12);
+}
+
+.log-card {
+  background: #262626;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 5px;
+  padding: 0.6rem 0.75rem;
+  margin-bottom: 0.65rem;
+  text-align: left;
+}
+
+.log-card-header {
+  display: flex;
+  align-items: center;
+  gap: 0.4rem;
+  margin-bottom: 0.4rem;
+  flex-wrap: wrap;
+}
+
+.log-target {
+  color: #38a6d8;
+  font-size: 0.85em;
+  background: rgba(56, 166, 216, 0.12);
+  padding: 0.15rem 0.4rem;
+  border-radius: 3px;
+}
+
+.log-json {
+  margin: 0;
+  max-height: 210px;
+  overflow: auto;
+  font-size: 0.78rem;
+  line-height: 1.35;
+  color: #ebebeb;
+  background: #141414;
+  padding: 0.5rem;
+  border-radius: 4px;
 }
 
 #map-example {
-  min-height: 300px;
+  min-height: 260px;
   width: 100%;
   z-index: 0;
+  border-radius: 4px;
 }
 
 #demo-dropdown>.default.text {
diff --git a/site/build.sh b/site/build.sh
index bc22a98..539a3fb 100755
--- a/site/build.sh
+++ b/site/build.sh
@@ -1,26 +1,30 @@
-#!/bin/bash
+#!/usr/bin/env bash
+# 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.
 
-set -e  # Exit immediately if any command fails
+set -euo pipefail
 
-# Step 1: Install Ruby dependencies (if Ruby is present)
-if command -v ruby &>/dev/null; then
-  echo "Installing Ruby dependencies..."
+if ! command -v bundle &>/dev/null; then
+  echo "Installing Bundler..."
   gem install bundler
-  bundle install
-else
-  echo "Ruby is not installed, skipping Ruby setup."
 fi
 
-# Step 2: Install Node.js dependencies (if npm is present)
-if command -v npm &>/dev/null; then
-  echo "Installing Node.js dependencies..."
-  npm install -g
-else
-  echo "npm is not installed, skipping Node.js setup."
-fi
+echo "Installing Ruby dependencies..."
+bundle install
 
-# Step 3: Build the Jekyll site
-echo "Building the Jekyll site..."
+echo "Building the Apache Flagon Jekyll site..."
 bundle exec jekyll build
 
-echo "Build completed successfully!"
+echo "Build completed successfully! Output written to _site/"
diff --git a/site/js/userale-demo.min.js b/site/js/userale-demo.min.js
index fcfb0a1..0763648 100644
--- a/site/js/userale-demo.min.js
+++ b/site/js/userale-demo.min.js
@@ -1 +1,2233 @@
-!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e(t.userale=t.userale||{})}(this,function(t){"use strict";function e(){var t={},e=document.currentScript||function(){var t=document.getElementsByTagName("script");return t[t.length-1]}(),o=e?e.getAttribute.bind(e):function(){return null};return t.autostart="false"!==o("data-autostart"),t.url=o("data-url")||"http://localhost:8000",t.transmitInterval=+o [...]
\ No newline at end of file
+/* 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.*/
+"use strict";
+(() => {
+  // src/packageLogs.ts
+  var logs;
+  var config;
+  var intervalId;
+  var intervalType;
+  var intervalPath;
+  var intervalTimer;
+  var intervalCounter;
+  var intervalLog;
+  var filterHandler = null;
+  var mapHandler = null;
+  var cbHandlers = {};
+  function addCallbacks(...newCallbacks) {
+    newCallbacks.forEach((source) => {
+      let descriptors = {};
+      descriptors = Object.keys(source).reduce((descriptors2, key) => {
+        descriptors2[key] = Object.getOwnPropertyDescriptor(source, key);
+        return descriptors2;
+      }, descriptors);
+      Object.getOwnPropertySymbols(source).forEach((sym) => {
+        const descriptor = Object.getOwnPropertyDescriptor(source, sym);
+        if (descriptor?.enumerable) {
+          descriptors[sym] = descriptor;
+        }
+      });
+      Object.defineProperties(cbHandlers, descriptors);
+    });
+    return cbHandlers;
+  }
+  function removeCallbacks(targetKeys) {
+    targetKeys.forEach((key) => {
+      if (Object.prototype.hasOwnProperty.call(cbHandlers, key)) {
+        delete cbHandlers[key];
+      }
+    });
+  }
+  function initPackager(newLogs, newConfig) {
+    logs = newLogs;
+    config = newConfig;
+    cbHandlers = {};
+    intervalId = null;
+    intervalType = null;
+    intervalPath = null;
+    intervalTimer = null;
+    intervalCounter = 0;
+    intervalLog = null;
+  }
+  function detectBrowser() {
+    try {
+      const nav = typeof self !== "undefined" ? self.navigator : null;
+      if (!nav) {
+        return { browser: null, version: null };
+      }
+      const brands = nav.userAgentData?.brands;
+      if (Array.isArray(brands) && brands.length > 0) {
+        const meaningful = brands.find(
+          (b) => b.brand && !/not.?a.?brand|chromium/i.test(b.brand)
+        ) ?? brands[0];
+        if (meaningful?.brand) {
+          return {
+            browser: meaningful.brand.toLowerCase(),
+            version: meaningful.version || null
+          };
+        }
+      }
+      const ua = typeof nav.userAgent === "string" ? nav.userAgent : "";
+      if (!ua) {
+        return { browser: null, version: null };
+      }
+      const rules = [
+        ["edge", /Edg(?:e|A|iOS)?\/([\d.]+)/],
+        ["opera", /(?:OPR|Opera)\/([\d.]+)/],
+        ["chrome", /(?:Chrome|CriOS)\/([\d.]+)/],
+        ["firefox", /(?:Firefox|FxiOS)\/([\d.]+)/],
+        ["safari", /Version\/([\d.]+).*Safari/]
+      ];
+      for (const [name, regex] of rules) {
+        const match = ua.match(regex);
+        if (match) {
+          return { browser: name, version: match[1] ?? null };
+        }
+      }
+    } catch {
+    }
+    return { browser: null, version: null };
+  }
+  var DISCRETE_RECT_EVENTS = /* @__PURE__ */ new Set(["click", "dblclick", "change", "submit"]);
+  function getTargetRect(target) {
+    try {
+      if (target && typeof target.getBoundingClientRect === "function") {
+        const rect = target.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 {
+    }
+    return null;
+  }
+  function packageLog(e, detailFcn) {
+    if (!config.on) {
+      return false;
+    }
+    let details = null;
+    if (detailFcn) {
+      details = detailFcn(e);
+    }
+    const timeFields = extractTimeFields(
+      e.timeStamp && e.timeStamp > 0 ? config.time(e.timeStamp) : Date.now()
+    );
+    let log2 = {
+      target: e.target ? getSelector(e.target) : null,
+      path: buildPath(e),
+      pageUrl: self.location.href,
+      pageTitle: document.title,
+      pageReferrer: document.referrer,
+      browser: detectBrowser(),
+      userAgent: self.navigator.userAgent,
+      clientTime: timeFields.milli,
+      microTime: timeFields.micro,
+      location: getLocation(e),
+      scrnRes: getScreenRes(),
+      type: e.type,
+      logType: "raw",
+      userAction: true,
+      details,
+      userId: config.userId,
+      toolVersion: config.toolVersion,
+      toolName: config.toolName,
+      useraleVersion: config.useraleVersion,
+      sessionId: config.sessionId,
+      httpSessionId: config.httpSessionId,
+      browserSessionId: config.browserSessionId,
+      attributes: buildAttrs(e),
+      style: buildCSS(e)
+    };
+    if (DISCRETE_RECT_EVENTS.has(e.type) && e.target) {
+      const rect = getTargetRect(e.target);
+      if (rect && (rect.width > 0 || rect.height > 0 || rect.x !== 0 || rect.y !== 0)) {
+        log2.targetRect = rect;
+      }
+    }
+    if (typeof filterHandler === "function" && !filterHandler(log2)) {
+      return false;
+    }
+    if (typeof mapHandler === "function") {
+      log2 = mapHandler(log2, e);
+    }
+    for (const func of Object.values(cbHandlers)) {
+      if (typeof func === "function") {
+        log2 = func(log2, e);
+        if (!log2) {
+          return false;
+        }
+      }
+    }
+    logs.push(log2);
+    return true;
+  }
+  function packageCustomLog(customLog, detailFcn, userAction) {
+    if (!config.on) {
+      return false;
+    }
+    let details = null;
+    if (typeof detailFcn === "function") {
+      details = detailFcn();
+    }
+    const metaData = {
+      pageUrl: self.location.href,
+      pageTitle: document.title,
+      pageReferrer: document.referrer,
+      browser: detectBrowser(),
+      userAgent: self.navigator.userAgent,
+      clientTime: Date.now(),
+      scrnRes: getScreenRes(),
+      logType: "custom",
+      userAction,
+      details,
+      userId: config.userId,
+      toolVersion: config.toolVersion,
+      toolName: config.toolName,
+      useraleVersion: config.useraleVersion,
+      sessionId: config.sessionId,
+      httpSessionId: config.httpSessionId,
+      browserSessionId: config.browserSessionId
+    };
+    let log2 = Object.assign(metaData, customLog);
+    if (typeof filterHandler === "function" && !filterHandler(log2)) {
+      return false;
+    }
+    if (typeof mapHandler === "function") {
+      log2 = mapHandler(log2);
+    }
+    for (const func of Object.values(cbHandlers)) {
+      if (typeof func === "function") {
+        log2 = func(log2, null);
+        if (!log2) {
+          return false;
+        }
+      }
+    }
+    logs.push(log2);
+    return true;
+  }
+  function extractTimeFields(timeStamp) {
+    return {
+      milli: Math.floor(timeStamp),
+      micro: Number((timeStamp % 1).toFixed(3))
+    };
+  }
+  function packageIntervalLog(e) {
+    try {
+      const target = e.target ? getSelector(e.target) : null;
+      const path = buildPath(e);
+      const type = e.type;
+      const timestamp = Math.floor(
+        e.timeStamp && e.timeStamp > 0 ? config.time(e.timeStamp) : Date.now()
+      );
+      if (intervalId == null) {
+        intervalId = target;
+        intervalType = type;
+        intervalPath = path;
+        intervalTimer = timestamp;
+        intervalCounter = 0;
+      }
+      if ((intervalId !== target || intervalType !== type) && intervalTimer) {
+        intervalLog = {
+          target: intervalId,
+          path: intervalPath,
+          pageUrl: self.location.href,
+          pageTitle: document.title,
+          pageReferrer: document.referrer,
+          browser: detectBrowser(),
+          userAgent: self.navigator.userAgent,
+          count: intervalCounter,
+          duration: timestamp - intervalTimer,
+          // microseconds
+          startTime: intervalTimer,
+          endTime: timestamp,
+          type: intervalType,
+          logType: "interval",
+          targetChange: intervalId !== target,
+          typeChange: intervalType !== type,
+          userAction: false,
+          userId: config.userId,
+          toolVersion: config.toolVersion,
+          toolName: config.toolName,
+          useraleVersion: config.useraleVersion,
+          sessionId: config.sessionId,
+          httpSessionId: config.httpSessionId,
+          browserSessionId: config.browserSessionId
+        };
+        if (typeof filterHandler === "function" && !filterHandler(intervalLog)) {
+          return false;
+        }
+        if (typeof mapHandler === "function") {
+          intervalLog = mapHandler(intervalLog, e);
+        }
+        for (const func of Object.values(cbHandlers)) {
+          if (typeof func === "function") {
+            intervalLog = func(intervalLog, null);
+            if (!intervalLog) {
+              return false;
+            }
+          }
+        }
+        if (intervalLog) logs.push(intervalLog);
+        intervalId = target;
+        intervalType = type;
+        intervalPath = path;
+        intervalTimer = timestamp;
+        intervalCounter = 0;
+      }
+      if (intervalId == target && intervalType == type && intervalCounter !== null) {
+        intervalCounter = intervalCounter + 1;
+      }
+      return true;
+    } catch {
+      return false;
+    }
+  }
+  function getLocation(e) {
+    if (e instanceof MouseEvent) {
+      if (e.pageX != null) {
+        return { x: e.pageX, y: e.pageY };
+      } else if (e.clientX != null) {
+        return {
+          x: document.documentElement.scrollLeft + e.clientX,
+          y: document.documentElement.scrollTop + e.clientY
+        };
+      }
+    } else {
+      return { x: null, y: null };
+    }
+  }
+  function getScreenRes() {
+    return { width: self.innerWidth, height: self.innerHeight };
+  }
+  function getSelector(ele) {
+    if (ele instanceof HTMLElement || ele instanceof Element) {
+      const rawClass = typeof ele.className === "string" ? ele.className : ele.getAttribute?.("class") ?? "";
+      const tag = ele.localName || ele.nodeName;
+      if (tag) {
+        return tag + (ele.id ? "#" + ele.id : "") + (rawClass ? "." + rawClass : "");
+      }
+    } else if (ele instanceof Document) {
+      return "#document";
+    } else if (ele === globalThis) {
+      return "Window";
+    }
+    return "Unknown";
+  }
+  function buildPath(e) {
+    const path = e.composedPath();
+    return selectorizePath(path);
+  }
+  function selectorizePath(path) {
+    let i = 0;
+    let pathEle;
+    const pathSelectors = [];
+    while (pathEle = path[i]) {
+      pathSelectors.push(getSelector(pathEle));
+      ++i;
+    }
+    return pathSelectors;
+  }
+  var attributeBlackList = ["style", "data-api-key", "data-auth"];
+  var sensitiveAttributePattern = /^data-(api-key|auth|token|secret|password)/i;
+  var jsonValuePattern = /^(?:\{[\s\S]*\}|\[[\s\S]*\]|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)$/;
+  function buildAttrs(e) {
+    const attributes = {};
+    if (e.target && e.target instanceof Element) {
+      for (const attr of e.target.attributes) {
+        if (attributeBlackList.includes(attr.name.toLowerCase()) || sensitiveAttributePattern.test(attr.name)) {
+          continue;
+        }
+        let val = attr.value;
+        if (jsonValuePattern.test(val.trim())) {
+          try {
+            val = JSON.parse(val);
+          } catch {
+          }
+        }
+        attributes[attr.name] = val;
+      }
+    }
+    return attributes;
+  }
+  function buildCSS(e) {
+    const properties = {};
+    if (e.target && e.target instanceof HTMLElement) {
+      const styleObj = e.target.style;
+      for (let i = 0; i < styleObj.length; i++) {
+        const prop = styleObj[i];
+        properties[prop] = styleObj.getPropertyValue(prop);
+      }
+    }
+    return properties;
+  }
+
+  // src/attachHandlers.ts
+  var events;
+  var bufferBools;
+  var bufferedEvents;
+  var refreshEvents;
+  var intervalEvents = [
+    "click",
+    "focus",
+    "blur",
+    "input",
+    "change",
+    "mouseover",
+    "submit"
+  ];
+  var windowEvents = ["load", "blur", "focus"];
+  function extractMouseDetails(e) {
+    return {
+      clicks: e.detail,
+      ctrl: e.ctrlKey,
+      alt: e.altKey,
+      shift: e.shiftKey,
+      meta: e.metaKey
+      //    'text' : e.target.innerHTML
+    };
+  }
+  function isPasswordInput(target) {
+    return typeof HTMLInputElement !== "undefined" && target instanceof HTMLInputElement && target.type?.toLowerCase() === "password";
+  }
+  function isRedactedElement(target) {
+    if (!target || typeof Element === "undefined" || !(target instanceof Element)) {
+      return false;
+    }
+    const redactSelector = '[data-userale-redact]:not([data-userale-redact="false"]), [data-userale-mask]:not([data-userale-mask="false"]), .userale-redact, .userale-mask';
+    if (typeof target.closest === "function") {
+      return Boolean(target.closest(redactSelector));
+    }
+    return target.hasAttribute("data-userale-redact") || target.hasAttribute("data-userale-mask");
+  }
+  function extractKeyboardDetails(e) {
+    if (isPasswordInput(e.target)) {
+      return null;
+    }
+    if (isRedactedElement(e.target)) {
+      return {
+        key: "[REDACTED]",
+        code: "[REDACTED]",
+        ctrl: e.ctrlKey,
+        alt: e.altKey,
+        shift: e.shiftKey,
+        meta: e.metaKey
+      };
+    }
+    return {
+      key: e.key,
+      code: e.code,
+      ctrl: e.ctrlKey,
+      alt: e.altKey,
+      shift: e.shiftKey,
+      meta: e.metaKey
+    };
+  }
+  function extractInputDetails(e) {
+    if (isPasswordInput(e.target)) {
+      return null;
+    }
+    if (isRedactedElement(e.target)) {
+      return {
+        value: "[REDACTED]"
+      };
+    }
+    return {
+      value: e.target?.value
+    };
+  }
+  function extractChangeDetails(e) {
+    if (isPasswordInput(e.target)) {
+      return null;
+    }
+    if (isRedactedElement(e.target)) {
+      return {
+        value: "[REDACTED]"
+      };
+    }
+    return {
+      value: e.target.value
+    };
+  }
+  function extractWheelDetails(e) {
+    return {
+      x: e.deltaX,
+      y: e.deltaY,
+      z: e.deltaZ
+    };
+  }
+  function extractScrollDetails() {
+    return {
+      x: window.scrollX,
+      y: window.scrollY
+    };
+  }
+  function extractResizeDetails() {
+    return {
+      width: window.outerWidth,
+      height: window.outerHeight
+    };
+  }
+  function defineDetails(config3) {
+    events = {
+      click: extractMouseDetails,
+      dblclick: extractMouseDetails,
+      mousedown: extractMouseDetails,
+      mouseup: extractMouseDetails,
+      focus: null,
+      blur: null,
+      input: config3.logDetails ? extractInputDetails : null,
+      change: config3.logDetails ? extractChangeDetails : null,
+      dragstart: null,
+      dragend: null,
+      drag: null,
+      drop: null,
+      keydown: config3.logDetails ? extractKeyboardDetails : null,
+      mouseover: null
+    };
+    bufferBools = {};
+    bufferedEvents = {
+      wheel: extractWheelDetails,
+      scroll: extractScrollDetails,
+      resize: extractResizeDetails
+    };
+    refreshEvents = {
+      submit: null
+    };
+  }
+  function defineCustomDetails(options2, type) {
+    const eventType = {
+      click: extractMouseDetails,
+      dblclick: extractMouseDetails,
+      mousedown: extractMouseDetails,
+      mouseup: extractMouseDetails,
+      focus: null,
+      blur: null,
+      load: null,
+      input: options2.logDetails ? extractInputDetails : null,
+      change: options2.logDetails ? extractChangeDetails : null,
+      dragstart: null,
+      dragend: null,
+      drag: null,
+      drop: null,
+      keydown: options2.logDetails ? extractKeyboardDetails : null,
+      mouseover: null,
+      wheel: extractWheelDetails,
+      scroll: extractScrollDetails,
+      resize: extractResizeDetails,
+      submit: null
+    };
+    return eventType[type];
+  }
+  function attachHandlers(config3) {
+    try {
+      defineDetails(config3);
+      Object.keys(events).forEach(function(ev) {
+        document.addEventListener(
+          ev,
+          function(e) {
+            packageLog(e, events[ev]);
+          },
+          true
+        );
+      });
+      intervalEvents.forEach(function(ev) {
+        document.addEventListener(
+          ev,
+          function(e) {
+            packageIntervalLog(e);
+          },
+          true
+        );
+      });
+      Object.keys(bufferedEvents).forEach(
+        function(ev) {
+          bufferBools[ev] = true;
+          self.addEventListener(
+            ev,
+            function(e) {
+              if (bufferBools[ev]) {
+                bufferBools[ev] = false;
+                packageLog(e, bufferedEvents[ev]);
+                setTimeout(function() {
+                  bufferBools[ev] = true;
+                }, config3.resolution);
+              }
+            },
+            true
+          );
+        }
+      );
+      Object.keys(refreshEvents).forEach(
+        function(ev) {
+          document.addEventListener(
+            ev,
+            function(e) {
+              packageLog(e, events[ev]);
+            },
+            true
+          );
+        }
+      );
+      windowEvents.forEach(function(ev) {
+        self.addEventListener(
+          ev,
+          function(e) {
+            packageLog(e, function() {
+              return { window: true };
+            });
+          },
+          true
+        );
+      });
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
+  // src/utils/auth/index.ts
+  var authCallback = null;
+  function updateAuthHeader(config3) {
+    if (authCallback) {
+      try {
+        config3.authHeader = authCallback();
+      } catch (e) {
+        console.error(`Error encountered while setting the auth header: ${e}`);
+      }
+    }
+  }
+  function registerAuthCallback(callback) {
+    try {
+      verifyCallback(callback);
+      authCallback = callback;
+      return true;
+    } catch {
+      return false;
+    }
+  }
+  function verifyCallback(callback) {
+    if (typeof callback !== "function") {
+      throw new Error("Userale auth callback must be a function");
+    }
+    const result = callback();
+    if (typeof result !== "string") {
+      throw new Error("Userale auth callback must return a string");
+    }
+  }
+  function resetAuthCallback() {
+    authCallback = null;
+  }
+
+  // src/utils/headers/index.ts
+  var headersCallback = null;
+  function updateCustomHeaders(config3) {
+    if (headersCallback) {
+      try {
+        config3.headers = headersCallback();
+      } catch (e) {
+        console.error(`Error encountered while setting the headers: ${e}`);
+      }
+    }
+  }
+  function registerHeadersCallback(callback) {
+    try {
+      verifyCallback2(callback);
+      headersCallback = callback;
+      return true;
+    } catch {
+      return false;
+    }
+  }
+  function verifyCallback2(callback) {
+    if (typeof callback !== "function") {
+      throw new Error("Userale headers callback must be a function");
+    }
+    const result = callback();
+    if (!result || typeof result !== "object" || Array.isArray(result)) {
+      throw new Error("Userale headers callback must return an object");
+    }
+    for (const [key, value] of Object.entries(result)) {
+      if (typeof key !== "string" || typeof value !== "string") {
+        throw new Error(
+          "Userale header callback must return an object with string keys and values"
+        );
+      }
+    }
+  }
+  function resetHeadersCallback() {
+    headersCallback = null;
+  }
+
+  // package.json
+  var version = "2.4.0";
+
+  // src/getInitialSettings.ts
+  var sessionId = null;
+  var httpSessionId = null;
+  function getInitialSettings() {
+    if (typeof WorkerGlobalScope !== "undefined" && self instanceof WorkerGlobalScope || typeof document === "undefined" || typeof window === "undefined") {
+      const settings2 = {
+        apiKey: null,
+        authHeader: null,
+        autostart: true,
+        browserSessionId: null,
+        custIndex: null,
+        exporter: null,
+        exporterFormat: "http-json",
+        exporterType: "http-json",
+        headers: null,
+        httpSessionId: null,
+        logCountThreshold: 5,
+        logDetails: false,
+        observeMutations: false,
+        otlpEndpoint: null,
+        resolution: 500,
+        sessionId,
+        time: (ts) => ts !== void 0 ? ts : Date.now(),
+        toolName: null,
+        toolVersion: null,
+        traceContext: null,
+        transmitInterval: 5e3,
+        url: "http://localhost:8000",
+        useraleVersion: null,
+        userFromParams: null,
+        userId: null
+      };
+      return settings2;
+    }
+    if (sessionId === null) {
+      sessionId = getsessionId(
+        "userAlesessionId",
+        "session_" + String(Date.now())
+      );
+    }
+    if (httpSessionId === null) {
+      httpSessionId = getsessionId(
+        "userAleHttpSessionId",
+        generatehttpSessionId()
+      );
+    }
+    const script = document.currentScript || (function() {
+      const scripts = document.getElementsByTagName("script");
+      return scripts[scripts.length - 1];
+    })();
+    const get = script ? script.getAttribute.bind(script) : function() {
+      return null;
+    };
+    const headers = get("data-headers");
+    const otlpEndpoint = get("data-otlp-endpoint") || null;
+    const exporterAttr = get("data-exporter-type") || get("data-exporter-format") || get("data-exporter") || (otlpEndpoint ? "otlp" : "http-json");
+    const settings = {
+      apiKey: get("data-api-key") || null,
+      authHeader: get("data-auth") || null,
+      autostart: get("data-autostart") === "false" ? false : true,
+      browserSessionId: null,
+      custIndex: get("data-index") || null,
+      exporter: null,
+      exporterFormat: exporterAttr,
+      exporterType: exporterAttr,
+      headers: headers ? JSON.parse(headers) : null,
+      httpSessionId,
+      logCountThreshold: +(get("data-threshold") || 5),
+      logDetails: get("data-log-details") === "true" ? true : false,
+      observeMutations: get("data-observe-mutations") === "true" ? true : false,
+      otlpEndpoint,
+      resolution: +(get("data-resolution") || 500),
+      sessionId: get("data-session") || sessionId,
+      time: timeStampScale(document.createEvent("CustomEvent")),
+      toolName: get("data-tool") || null,
+      toolVersion: get("data-version") || null,
+      traceContext: null,
+      transmitInterval: +(get("data-interval") || 5e3),
+      url: get("data-url") || "http://localhost:8000",
+      useraleVersion: get("data-userale-version") || null,
+      userFromParams: get("data-user-from-params") || null,
+      userId: get("data-user") || null
+    };
+    return settings;
+  }
+  function getsessionId(sessionKey, value) {
+    if (self.sessionStorage.getItem(sessionKey) === null) {
+      self.sessionStorage.setItem(sessionKey, JSON.stringify(value));
+      return value;
+    }
+    return JSON.parse(self.sessionStorage.getItem(sessionKey) || "");
+  }
+  function timeStampScale(e) {
+    let tsScaler;
+    if (e.timeStamp && e.timeStamp > 0) {
+      const delta = Date.now() - e.timeStamp;
+      if (delta < 0) {
+        tsScaler = function() {
+          return e.timeStamp / 1e3;
+        };
+      } else if (delta > e.timeStamp) {
+        const navStart = performance.timeOrigin;
+        tsScaler = function(ts) {
+          return ts + navStart;
+        };
+      } else {
+        tsScaler = function(ts) {
+          return ts;
+        };
+      }
+    } else {
+      tsScaler = function() {
+        return Date.now();
+      };
+    }
+    return tsScaler;
+  }
+  function generatehttpSessionId() {
+    const len = 32;
+    const arr = new Uint8Array(len / 2);
+    window.crypto.getRandomValues(arr);
+    return Array.from(arr, (dec) => {
+      return dec.toString(16).padStart(2, "0");
+    }).join("");
+  }
+
+  // src/configure.ts
+  var _Configuration = class _Configuration {
+    // Private constructor to prevent external instantiation
+    constructor() {
+      // Public properties corresponding to fields in the Config interface
+      this.apiKey = null;
+      this.autostart = false;
+      this.authHeader = null;
+      this.browserSessionId = null;
+      this.custIndex = null;
+      this.exporter = null;
+      this.exporterFormat = "http-json";
+      this.exporterType = "http-json";
+      this.headers = null;
+      this.httpSessionId = null;
+      this.logCountThreshold = 0;
+      this.logDetails = false;
+      this.observeMutations = false;
+      this.on = false;
+      this.otlpEndpoint = null;
+      this.resolution = 0;
+      this.sessionId = null;
+      this.time = () => Date.now();
+      this.toolName = null;
+      this.toolVersion = null;
+      this.traceContext = null;
+      this.transmitInterval = 0;
+      this.url = "";
+      this.userFromParams = null;
+      this.useraleVersion = null;
+      this.userId = null;
+      this.version = null;
+      this.websocketsEnabled = false;
+      if (_Configuration.instance === null) {
+        this.initialize();
+      }
+    }
+    // Static method to get the singleton instance
+    static getInstance() {
+      if (_Configuration.instance === null) {
+        _Configuration.instance = new _Configuration();
+      }
+      return _Configuration.instance;
+    }
+    initialize() {
+      const settings = getInitialSettings();
+      this.update(settings);
+    }
+    /**
+     * Resets the configuration to its initial state.
+     */
+    reset() {
+      this.initialize();
+    }
+    /**
+     * Shallow merges a newConfig with the configuration class, updating it.
+     * Retrieves/updates the userid if userFromParams is provided.
+     * @param  {Partial<Settings.Config>} newConfig Configuration object to merge into the current config.
+     */
+    update(newConfig) {
+      Object.keys(newConfig).forEach((option) => {
+        if (option === "userFromParams") {
+          const userParamString = newConfig[option];
+          const userId = userParamString ? _Configuration.getUserIdFromParams(userParamString) : null;
+          if (userId) {
+            this["userId"] = userId;
+          }
+        }
+        const hasNewUserFromParams = newConfig["userFromParams"];
+        const willNullifyUserId = option === "userId" && newConfig[option] === null;
+        if (willNullifyUserId && hasNewUserFromParams) {
+          return;
+        }
+        const newOption = newConfig[option];
+        if (newOption !== void 0) {
+          this[option] = newOption;
+          if (option === "exporterFormat" && newConfig.exporterType === void 0) {
+            this.exporterType = newOption;
+          } else if (option === "exporterType" && newConfig.exporterFormat === void 0) {
+            this.exporterFormat = newOption;
+          }
+        }
+      });
+    }
+    /**
+     * Attempts to extract the userid from the query parameters of the URL.
+     * @param  {string} param The name of the query parameter containing the userid.
+     * @return {string | null}       The extracted/decoded userid, or null if none is found.
+     */
+    static getUserIdFromParams(param) {
+      const userField = param;
+      const regex = new RegExp("[?&]" + userField + "(=([^&#]*)|&|#|$)");
+      const results = window.location.href.match(regex);
+      if (results && results[2]) {
+        return decodeURIComponent(results[2].replace(/\+/g, " "));
+      }
+      return null;
+    }
+  };
+  // Private static property to hold the singleton instance
+  _Configuration.instance = null;
+  var Configuration = _Configuration;
+
+  // src/exporters/headers.ts
+  function buildHeaders(config3) {
+    const headers = new Headers({
+      "Content-Type": "application/json;charset=UTF-8"
+    });
+    updateAuthHeader(config3);
+    if (config3.authHeader) {
+      const authHeaderValue = typeof config3.authHeader === "function" ? config3.authHeader() : String(config3.authHeader);
+      headers.set("Authorization", authHeaderValue);
+    }
+    if (config3.apiKey) {
+      headers.set("x-api-key", config3.apiKey);
+    }
+    updateCustomHeaders(config3);
+    if (config3.headers) {
+      for (const [header, value] of Object.entries(config3.headers)) {
+        headers.set(header, value);
+      }
+    }
+    return headers;
+  }
+
+  // src/exporters/http-json.ts
+  var MAX_KEEPALIVE_BYTES = 48 * 1024;
+  function chunkLogsForKeepalive(logs3, serialize) {
+    const full = serialize(logs3);
+    if (full.length <= MAX_KEEPALIVE_BYTES || logs3.length <= 1) {
+      return [full];
+    }
+    const mid = Math.ceil(logs3.length / 2);
+    return [
+      ...chunkLogsForKeepalive(logs3.slice(0, mid), serialize),
+      ...chunkLogsForKeepalive(logs3.slice(mid), serialize)
+    ];
+  }
+  var HttpJsonExporter = class {
+    constructor(config3) {
+      this.config = config3;
+    }
+    serialize(logs3) {
+      return JSON.stringify(logs3);
+    }
+    getEndpoint() {
+      return this.config.url;
+    }
+    async export(logs3, options2) {
+      const headers = buildHeaders(this.config);
+      const endpoint = this.getEndpoint();
+      const keepalive = Boolean(options2?.keepalive);
+      const retries = options2?.retries ?? 0;
+      if (keepalive) {
+        const payloads = chunkLogsForKeepalive(
+          logs3,
+          (batch) => this.serialize(batch)
+        );
+        try {
+          await Promise.all(
+            payloads.map(
+              (body2) => fetch(endpoint, {
+                keepalive: true,
+                method: "POST",
+                headers,
+                body: body2
+              })
+            )
+          );
+          return { success: true };
+        } catch (err) {
+          const error = err instanceof Error ? err : new Error(String(err));
+          console.error(error);
+          return { success: false, error };
+        }
+      }
+      const body = this.serialize(logs3);
+      async function attemptSend(remainingRetries) {
+        try {
+          const response = await fetch(endpoint, {
+            method: "POST",
+            headers,
+            body
+          });
+          if (!response.ok) {
+            if (remainingRetries > 0) {
+              return attemptSend(remainingRetries - 1);
+            }
+            throw new Error(`Failed to send logs: ${response.statusText}`);
+          }
+        } catch (error) {
+          if (remainingRetries > 0) {
+            return attemptSend(remainingRetries - 1);
+          }
+          throw error;
+        }
+      }
+      await attemptSend(retries);
+      return { success: true };
+    }
+    async flush() {
+      return Promise.resolve();
+    }
+    async shutdown() {
+      return Promise.resolve();
+    }
+  };
+
+  // src/exporters/otel-sdk.ts
+  var OtelSdkExporter = class {
+    constructor(logger) {
+      this.logger = logger;
+    }
+    async export(logs3) {
+      try {
+        for (const log2 of logs3) {
+          const logType = typeof log2.logType === "string" ? log2.logType : "raw";
+          const eventType = typeof log2.type === "string" && log2.type.trim().length > 0 ? log2.type.trim() : logType;
+          const eventName = `userale.${logType}.${eventType}`;
+          const timestamp = typeof log2.clientTime === "number" ? log2.clientTime : typeof log2.endTime === "number" ? log2.endTime : Date.now();
+          const attributes = {
+            "event.name": eventName,
+            "flagon.log_type": logType,
+            "flagon.event_type": eventType
+          };
+          if (typeof log2.pageUrl === "string") {
+            attributes["url.full"] = log2.pageUrl;
+          }
+          if (typeof log2.pageTitle === "string") {
+            attributes["page.title"] = log2.pageTitle;
+          }
+          if (typeof log2.userId === "string") {
+            attributes["user.id"] = log2.userId;
+          }
+          if (typeof log2.sessionId === "string") {
+            attributes["session.id"] = log2.sessionId;
+          }
+          if (typeof log2.target === "string") {
+            attributes["flagon.target"] = log2.target;
+          }
+          if (Array.isArray(log2.path)) {
+            attributes["flagon.path"] = log2.path;
+          }
+          if (typeof log2.userAction === "boolean") {
+            attributes["flagon.user_action"] = log2.userAction;
+          }
+          const loc = log2.location;
+          if (loc && typeof loc === "object") {
+            if (typeof loc.x === "number") {
+              attributes["flagon.location.x"] = loc.x;
+            }
+            if (typeof loc.y === "number") {
+              attributes["flagon.location.y"] = loc.y;
+            }
+          }
+          this.logger.emit({
+            timestamp,
+            observedTimestamp: timestamp,
+            severityNumber: 9,
+            severityText: "INFO",
+            eventName,
+            body: log2.details ?? eventName,
+            attributes
+          });
+        }
+        return { success: true };
+      } catch (err) {
+        const error = err instanceof Error ? err : new Error(String(err));
+        return { success: false, error };
+      }
+    }
+  };
+
+  // src/exporters/otlp-json.ts
+  var HEX_32_RE = /^[0-9a-f]{32}$/i;
+  var HEX_16_RE = /^[0-9a-f]{16}$/i;
+  var ALL_ZEROS_RE = /^0+$/;
+  var KNOWN_LOG_KEYS = /* @__PURE__ */ new Set([
+    "target",
+    "path",
+    "pageUrl",
+    "pageTitle",
+    "pageReferrer",
+    "browser",
+    "userAgent",
+    "clientTime",
+    "scrnRes",
+    "microTime",
+    "location",
+    "type",
+    "logType",
+    "userAction",
+    "details",
+    "userId",
+    "toolVersion",
+    "toolName",
+    "useraleVersion",
+    "sessionId",
+    "httpSessionId",
+    "browserSessionId",
+    "attributes",
+    "style",
+    "count",
+    "duration",
+    "startTime",
+    "endTime",
+    "targetChange",
+    "typeChange",
+    "targetRect",
+    "traceId",
+    "spanId"
+  ]);
+  function resolveOtlpLogsEndpoint(rawUrl) {
+    try {
+      const parsed = new URL(
+        rawUrl,
+        typeof window !== "undefined" && window.location ? window.location.origin : "http://localhost:4318"
+      );
+      if (parsed.pathname === "" || parsed.pathname === "/") {
+        parsed.pathname = "/v1/logs";
+        return parsed.toString();
+      }
+      return rawUrl;
+    } catch {
+      const trimmed = rawUrl.replace(/\/+$/, "");
+      return `${trimmed}/v1/logs`;
+    }
+  }
+  function toOtlpAnyValue(val) {
+    if (val === null || val === void 0) {
+      return { stringValue: "" };
+    }
+    if (typeof val === "string") {
+      return { stringValue: val };
+    }
+    if (typeof val === "boolean") {
+      return { boolValue: val };
+    }
+    if (typeof val === "bigint") {
+      return { intValue: val.toString(10) };
+    }
+    if (typeof val === "number") {
+      if (!Number.isFinite(val)) {
+        return { stringValue: String(val) };
+      }
+      if (Number.isInteger(val)) {
+        return { intValue: BigInt(val).toString(10) };
+      }
+      return { doubleValue: val };
+    }
+    if (Array.isArray(val)) {
+      return {
+        arrayValue: {
+          values: val.map((item) => toOtlpAnyValue(item))
+        }
+      };
+    }
+    if (typeof val === "object") {
+      const entries = Object.entries(val).filter(
+        ([, v]) => v !== void 0
+      );
+      return {
+        kvlistValue: {
+          values: entries.map(([key, v]) => ({
+            key,
+            value: toOtlpAnyValue(v)
+          }))
+        }
+      };
+    }
+    return { stringValue: String(val) };
+  }
+  function pushStringAttr(attrs, key, val) {
+    if (typeof val === "string" && val.length > 0) {
+      attrs.push({ key, value: { stringValue: val } });
+    }
+  }
+  function pushIntAttr(attrs, key, val) {
+    if (typeof val === "number" && Number.isFinite(val)) {
+      attrs.push({
+        key,
+        value: { intValue: BigInt(Math.trunc(val)).toString(10) }
+      });
+    }
+  }
+  function pushDoubleAttr(attrs, key, val) {
+    if (typeof val === "number" && Number.isFinite(val)) {
+      attrs.push({ key, value: { doubleValue: val } });
+    }
+  }
+  function pushBoolAttr(attrs, key, val) {
+    if (typeof val === "boolean") {
+      attrs.push({ key, value: { boolValue: val } });
+    }
+  }
+  function toUnixNanoString(clientTimeMs, microTime) {
+    const ms = typeof clientTimeMs === "number" && Number.isFinite(clientTimeMs) ? Math.trunc(clientTimeMs) : Date.now();
+    let subMsNanos = 0;
+    if (typeof microTime === "number" && Number.isFinite(microTime)) {
+      const frac = microTime - Math.trunc(microTime);
+      if (frac > 0 && frac < 1) {
+        subMsNanos = Math.round(frac * 1e6);
+      }
+    }
+    return (BigInt(ms) * BigInt(1e6) + BigInt(subMsNanos)).toString(10);
+  }
+  function resolveTraceContext(log2, config3) {
+    let rawTraceId = typeof log2.traceId === "string" ? log2.traceId : void 0;
+    let rawSpanId = typeof log2.spanId === "string" ? log2.spanId : void 0;
+    if ((!rawTraceId || !rawSpanId) && typeof config3?.traceContext === "function") {
+      try {
+        const ctx = config3.traceContext();
+        if (ctx) {
+          rawTraceId = rawTraceId ?? ctx.traceId;
+          rawSpanId = rawSpanId ?? ctx.spanId;
+        }
+      } catch {
+      }
+    }
+    if (!rawTraceId || !rawSpanId) {
+      const globalOtel = globalThis.opentelemetry;
+      const spanCtx = globalOtel?.trace?.getActiveSpan?.()?.spanContext?.();
+      if (spanCtx) {
+        rawTraceId = rawTraceId ?? spanCtx.traceId;
+        rawSpanId = rawSpanId ?? spanCtx.spanId;
+      }
+    }
+    const result = {};
+    if (rawTraceId && HEX_32_RE.test(rawTraceId) && !ALL_ZEROS_RE.test(rawTraceId)) {
+      result.traceId = rawTraceId.toLowerCase();
+    }
+    if (rawSpanId && HEX_16_RE.test(rawSpanId) && !ALL_ZEROS_RE.test(rawSpanId)) {
+      result.spanId = rawSpanId.toLowerCase();
+    }
+    return result;
+  }
+  function buildOtlpResourceAttributes(logs3, config3) {
+    const first = logs3[0] ?? {};
+    const attrs = [];
+    const serviceName = typeof first.toolName === "string" && first.toolName || config3?.toolName || "unknown_service:userale";
+    attrs.push({ key: "service.name", value: { stringValue: serviceName } });
+    const serviceVersion = typeof first.toolVersion === "string" && first.toolVersion || config3?.toolVersion;
+    pushStringAttr(attrs, "service.version", serviceVersion);
+    attrs.push({
+      key: "telemetry.sdk.name",
+      value: { stringValue: "apache-flagon-userale" }
+    });
+    attrs.push({
+      key: "telemetry.sdk.language",
+      value: { stringValue: "webjs" }
+    });
+    const sdkVersion = typeof first.useraleVersion === "string" && first.useraleVersion || config3?.useraleVersion || "2.4.0";
+    pushStringAttr(attrs, "telemetry.sdk.version", sdkVersion);
+    pushStringAttr(attrs, "user_agent.original", first.userAgent);
+    const browserObj = first.browser;
+    if (browserObj && typeof browserObj === "object") {
+      pushStringAttr(attrs, "browser.name", browserObj.browser);
+      pushStringAttr(attrs, "browser.version", browserObj.version);
+    }
+    const sessionId2 = typeof first.sessionId === "string" && first.sessionId || config3?.sessionId;
+    pushStringAttr(attrs, "session.id", sessionId2);
+    const httpSessionId2 = typeof first.httpSessionId === "string" && first.httpSessionId || config3?.httpSessionId;
+    pushStringAttr(attrs, "flagon.http_session_id", httpSessionId2);
+    const browserSessionId = typeof first.browserSessionId === "string" && first.browserSessionId || config3?.browserSessionId;
+    pushStringAttr(attrs, "flagon.browser_session_id", browserSessionId);
+    const scrnRes = first.scrnRes;
+    if (scrnRes && typeof scrnRes === "object") {
+      pushIntAttr(attrs, "screen.width", scrnRes.width);
+      pushIntAttr(attrs, "screen.height", scrnRes.height);
+    }
+    return attrs;
+  }
+  function toOtlpLogRecord(log2, config3) {
+    const logType = typeof log2.logType === "string" ? log2.logType : "raw";
+    const eventType = typeof log2.type === "string" && log2.type.trim().length > 0 ? log2.type.trim() : logType;
+    const eventName = `userale.${logType}.${eventType}`;
+    const primaryTimeMs = typeof log2.clientTime === "number" ? log2.clientTime : typeof log2.endTime === "number" ? log2.endTime : typeof log2.startTime === "number" ? log2.startTime : Date.now();
+    const timeUnixNano = toUnixNanoString(primaryTimeMs, log2.microTime);
+    const attrs = [
+      { key: "event.name", value: { stringValue: eventName } },
+      { key: "flagon.log_type", value: { stringValue: logType } },
+      { key: "flagon.event_type", value: { stringValue: eventType } }
+    ];
+    pushStringAttr(attrs, "url.full", log2.pageUrl);
+    pushStringAttr(attrs, "page.title", log2.pageTitle);
+    pushStringAttr(attrs, "page.referrer", log2.pageReferrer);
+    pushStringAttr(attrs, "user.id", log2.userId);
+    pushStringAttr(attrs, "flagon.target", log2.target);
+    pushBoolAttr(attrs, "flagon.user_action", log2.userAction);
+    if (Array.isArray(log2.path) && log2.path.length > 0) {
+      attrs.push({
+        key: "flagon.path",
+        value: {
+          arrayValue: {
+            values: log2.path.map((p) => ({ stringValue: String(p) }))
+          }
+        }
+      });
+    }
+    const loc = log2.location;
+    if (loc && typeof loc === "object") {
+      pushDoubleAttr(attrs, "flagon.location.x", loc.x);
+      pushDoubleAttr(attrs, "flagon.location.y", loc.y);
+    }
+    if (log2.targetRect && typeof log2.targetRect === "object" && !Array.isArray(log2.targetRect)) {
+      attrs.push({
+        key: "flagon.target_rect",
+        value: toOtlpAnyValue(log2.targetRect)
+      });
+    }
+    if (logType === "interval") {
+      pushIntAttr(attrs, "flagon.interval.count", log2.count);
+      pushIntAttr(attrs, "flagon.interval.duration_ms", log2.duration);
+      pushIntAttr(attrs, "flagon.interval.start_time_ms", log2.startTime);
+      pushIntAttr(attrs, "flagon.interval.end_time_ms", log2.endTime);
+      pushBoolAttr(attrs, "flagon.interval.target_change", log2.targetChange);
+      pushBoolAttr(attrs, "flagon.interval.type_change", log2.typeChange);
+    }
+    if (log2.attributes && typeof log2.attributes === "object" && !Array.isArray(log2.attributes) && Object.keys(log2.attributes).length > 0) {
+      attrs.push({
+        key: "flagon.element.attributes",
+        value: toOtlpAnyValue(log2.attributes)
+      });
+    }
+    if (log2.style && typeof log2.style === "object" && !Array.isArray(log2.style) && Object.keys(log2.style).length > 0) {
+      attrs.push({
+        key: "flagon.element.style",
+        value: toOtlpAnyValue(log2.style)
+      });
+    }
+    const extraFields = {};
+    for (const [key, val] of Object.entries(log2)) {
+      if (!KNOWN_LOG_KEYS.has(key) && val !== void 0) {
+        extraFields[key] = val;
+      }
+    }
+    const bodyObj = {};
+    if (log2.details && typeof log2.details === "object" && !Array.isArray(log2.details)) {
+      Object.assign(bodyObj, log2.details);
+    } else if (log2.details !== void 0 && log2.details !== null) {
+      bodyObj.details = log2.details;
+    }
+    Object.assign(bodyObj, extraFields);
+    const body = Object.keys(bodyObj).length > 0 ? toOtlpAnyValue(bodyObj) : {
+      stringValue: typeof log2.target === "string" && log2.target ? `${eventType} on ${log2.target}` : eventType
+    };
+    const record = {
+      timeUnixNano,
+      observedTimeUnixNano: timeUnixNano,
+      severityNumber: 9,
+      severityText: "INFO",
+      eventName,
+      body,
+      attributes: attrs
+    };
+    const traceCtx = resolveTraceContext(log2, config3);
+    if (traceCtx.traceId) {
+      record.traceId = traceCtx.traceId;
+    }
+    if (traceCtx.spanId) {
+      record.spanId = traceCtx.spanId;
+    }
+    return record;
+  }
+  function toOtlpExportLogsServiceRequest(logs3, config3) {
+    const first = logs3[0] ?? {};
+    const scopeVersion = typeof first.useraleVersion === "string" && first.useraleVersion || config3?.useraleVersion || "2.4.0";
+    return {
+      resourceLogs: [
+        {
+          resource: {
+            attributes: buildOtlpResourceAttributes(logs3, config3)
+          },
+          scopeLogs: [
+            {
+              scope: {
+                name: "org.apache.flagon.userale",
+                version: scopeVersion
+              },
+              logRecords: logs3.map((log2) => toOtlpLogRecord(log2, config3))
+            }
+          ]
+        }
+      ]
+    };
+  }
+  var OtlpJsonExporter = class extends HttpJsonExporter {
+    constructor(config3) {
+      super(config3);
+      this.otlpConfig = config3;
+    }
+    getEndpoint() {
+      const raw = this.otlpConfig.otlpEndpoint || this.otlpConfig.url;
+      return resolveOtlpLogsEndpoint(raw);
+    }
+    serialize(logs3) {
+      return JSON.stringify(
+        toOtlpExportLogsServiceRequest(logs3, this.otlpConfig)
+      );
+    }
+  };
+
+  // src/exporters/proto-json.ts
+  var KNOWN_CUSTOM_LOG_KEYS = /* @__PURE__ */ new Set([
+    "pageUrl",
+    "pageTitle",
+    "pageReferrer",
+    "browser",
+    "userAgent",
+    "clientTime",
+    "scrnRes",
+    "logType",
+    "userAction",
+    "details",
+    "userId",
+    "toolVersion",
+    "toolName",
+    "useraleVersion",
+    "sessionId",
+    "httpSessionId",
+    "browserSessionId"
+  ]);
+  function buildProtoMetadata(log2) {
+    const scrnRes = log2.scrnRes;
+    const metadata = {
+      pageUrl: typeof log2.pageUrl === "string" ? log2.pageUrl : "",
+      pageTitle: typeof log2.pageTitle === "string" ? log2.pageTitle : "",
+      pageReferrer: typeof log2.pageReferrer === "string" ? log2.pageReferrer : "",
+      userAgent: typeof log2.userAgent === "string" ? log2.userAgent : "",
+      clientTime: String(
+        typeof log2.clientTime === "number" ? Math.trunc(log2.clientTime) : typeof log2.startTime === "number" ? Math.trunc(log2.startTime) : 0
+      )
+    };
+    if (scrnRes && typeof scrnRes === "object") {
+      metadata.screenResolution = {
+        width: typeof scrnRes.width === "number" ? Math.trunc(scrnRes.width) : 0,
+        height: typeof scrnRes.height === "number" ? Math.trunc(scrnRes.height) : 0
+      };
+    }
+    if (typeof log2.userId === "string") metadata.userId = log2.userId;
+    if (typeof log2.sessionId === "string") metadata.sessionId = log2.sessionId;
+    if (typeof log2.httpSessionId === "string")
+      metadata.httpSessionId = log2.httpSessionId;
+    if (typeof log2.browserSessionId === "string")
+      metadata.browserSessionId = log2.browserSessionId;
+    if (typeof log2.toolName === "string") metadata.toolName = log2.toolName;
+    if (typeof log2.toolVersion === "string")
+      metadata.toolVersion = log2.toolVersion;
+    if (typeof log2.useraleVersion === "string")
+      metadata.useraleVersion = log2.useraleVersion;
+    return metadata;
+  }
+  function toStringAttributes(attrs) {
+    if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) {
+      return void 0;
+    }
+    const out = {};
+    for (const [k, v] of Object.entries(attrs)) {
+      out[k] = typeof v === "string" ? v : JSON.stringify(v);
+    }
+    return out;
+  }
+  function toProtoUserALELog(log2) {
+    const metadata = buildProtoMetadata(log2);
+    const logType = log2.logType;
+    if (logType === "raw") {
+      const loc = log2.location;
+      const location = {};
+      if (loc && typeof loc === "object") {
+        if (typeof loc.x === "number") location.x = loc.x;
+        if (typeof loc.y === "number") location.y = loc.y;
+      }
+      const raw = {
+        metadata,
+        target: typeof log2.target === "string" ? log2.target : "",
+        path: Array.isArray(log2.path) ? log2.path : [],
+        eventType: typeof log2.type === "string" ? log2.type : "",
+        location,
+        userAction: Boolean(log2.userAction)
+      };
+      if (typeof log2.microTime === "number") {
+        raw.microTime = log2.microTime;
+      }
+      if (log2.details && typeof log2.details === "object" && !Array.isArray(log2.details)) {
+        raw.details = log2.details;
+      }
+      const strAttrs = toStringAttributes(log2.attributes);
+      if (strAttrs) {
+        raw.attributes = strAttrs;
+      }
+      if (log2.style && typeof log2.style === "object" && !Array.isArray(log2.style)) {
+        raw.style = log2.style;
+      }
+      return {
+        logType: "LOG_TYPE_RAW",
+        raw
+      };
+    }
+    if (logType === "interval") {
+      const interval = {
+        metadata,
+        target: typeof log2.target === "string" ? log2.target : "",
+        path: Array.isArray(log2.path) ? log2.path : [],
+        eventType: typeof log2.type === "string" ? log2.type : "",
+        count: typeof log2.count === "number" ? Math.trunc(log2.count) : 0,
+        duration: String(
+          typeof log2.duration === "number" ? Math.trunc(log2.duration) : 0
+        ),
+        startTime: String(
+          typeof log2.startTime === "number" ? Math.trunc(log2.startTime) : 0
+        ),
+        endTime: String(
+          typeof log2.endTime === "number" ? Math.trunc(log2.endTime) : 0
+        ),
+        targetChange: Boolean(log2.targetChange),
+        typeChange: Boolean(log2.typeChange),
+        userAction: Boolean(log2.userAction)
+      };
+      return {
+        logType: "LOG_TYPE_INTERVAL",
+        interval
+      };
+    }
+    const customFields = {};
+    for (const [key, value] of Object.entries(log2)) {
+      if (!KNOWN_CUSTOM_LOG_KEYS.has(key) && value !== void 0) {
+        customFields[key] = value;
+      }
+    }
+    if (typeof logType === "string" && logType !== "custom") {
+      customFields.logType = logType;
+    }
+    const custom = {
+      metadata,
+      userAction: Boolean(log2.userAction),
+      customFields
+    };
+    if (log2.details && typeof log2.details === "object" && !Array.isArray(log2.details)) {
+      custom.details = log2.details;
+    }
+    return {
+      logType: "LOG_TYPE_CUSTOM",
+      custom
+    };
+  }
+  function toProtoLogBatch(logs3) {
+    return {
+      logs: logs3.map(toProtoUserALELog)
+    };
+  }
+  var ProtoJsonExporter = class extends HttpJsonExporter {
+    serialize(logs3) {
+      return JSON.stringify(toProtoLogBatch(logs3));
+    }
+  };
+
+  // src/exporters/websocket.ts
+  var WebSocketExporter = class {
+    constructor(config3) {
+      this.wsock = new WebSocket(config3.url);
+    }
+    async export(logs3) {
+      const data = JSON.stringify(logs3);
+      this.wsock.send(data);
+      return { success: true };
+    }
+    async flush() {
+      return Promise.resolve();
+    }
+    async shutdown() {
+      if (typeof WebSocket !== "undefined" && this.wsock.readyState === WebSocket.OPEN) {
+        this.wsock.close();
+      }
+      return Promise.resolve();
+    }
+  };
+
+  // src/exporters/index.ts
+  function createExporter(config3) {
+    if (config3.exporter) {
+      return config3.exporter;
+    }
+    const mode = config3.exporterFormat || config3.exporterType;
+    if (mode === "otlp" || mode === "otlp-json" || config3.otlpEndpoint) {
+      return new OtlpJsonExporter(config3);
+    }
+    try {
+      const url = new URL(config3.url);
+      if (url.protocol === "ws:" || url.protocol === "wss:" || mode === "websocket" || config3.websocketsEnabled) {
+        return new WebSocketExporter(config3);
+      }
+    } catch {
+    }
+    if (mode === "proto-json") {
+      return new ProtoJsonExporter(config3);
+    }
+    return new HttpJsonExporter(config3);
+  }
+
+  // src/sendLogs.ts
+  var sendIntervalId;
+  var pagehideHandler = null;
+  var activeExporter = null;
+  var activeExporterKey = null;
+  var activeCustomExporter = null;
+  function registerExporter(exporter) {
+    if (exporter !== null && typeof exporter?.export !== "function") {
+      return false;
+    }
+    Configuration.getInstance().update({ exporter });
+    activeExporter = null;
+    return true;
+  }
+  function resolveExporter(config3) {
+    const key = [
+      config3.url,
+      config3.otlpEndpoint ?? "",
+      config3.exporterFormat ?? "",
+      config3.exporterType ?? "http-json",
+      String(Boolean(config3.websocketsEnabled))
+    ].join("|");
+    if (activeExporter && activeExporterKey === key && activeCustomExporter === (config3.exporter ?? null)) {
+      return activeExporter;
+    }
+    activeExporter = createExporter(config3);
+    activeExporterKey = key;
+    activeCustomExporter = config3.exporter ?? null;
+    return activeExporter;
+  }
+  function initSender(logs3, config3) {
+    if (sendIntervalId) {
+      clearInterval(sendIntervalId);
+    }
+    try {
+      const url = new URL(config3.url);
+      if ((config3.apiKey || config3.authHeader) && url.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
+        console.warn(
+          "[UserALE] Sending credentials over plaintext HTTP. Use HTTPS in production."
+        );
+      }
+    } catch {
+    }
+    activeExporter = null;
+    resolveExporter(config3);
+    sendIntervalId = sendOnInterval(logs3, config3);
+    if (pagehideHandler && typeof self.removeEventListener === "function") {
+      self.removeEventListener("pagehide", pagehideHandler);
+      pagehideHandler = null;
+    }
+    sendOnClose(logs3, config3);
+  }
+  function sendOnInterval(logs3, config3) {
+    return setInterval(function() {
+      if (!config3.on) {
+        return;
+      }
+      if (logs3.length >= config3.logCountThreshold) {
+        sendLogs(logs3.slice(0), config3, 0);
+        logs3.splice(0);
+      }
+    }, config3.transmitInterval);
+  }
+  function sendOnClose(logs3, config3) {
+    const listener = function() {
+      if (!config3.on) {
+        return;
+      }
+      if (logs3.length > 0) {
+        const batch = logs3.slice(0);
+        logs3.splice(0);
+        const exporter = resolveExporter(config3);
+        exporter.export(batch, { keepalive: true }).catch((error) => {
+          console.error(error);
+        });
+      }
+    };
+    pagehideHandler = listener;
+    self.addEventListener("pagehide", listener);
+  }
+  async function sendLogs(logs3, config3, retries) {
+    const exporter = resolveExporter(config3);
+    await exporter.export(logs3, { retries });
+  }
+
+  // src/replay/index.ts
+  var DEFAULT_IGNORE_SELECTOR = "[data-userale-ignore], .userale-ignore";
+  var DEFAULT_MASK_SELECTOR = "[data-userale-mask], .userale-mask";
+  var MAX_KEEPALIVE_BYTES2 = 48 * 1024;
+  var ReplayRingBuffer = class {
+    constructor(maxDurationMs = 3e4, maxFrames = 2e3) {
+      this.frames = [];
+      this.anchorMeta = null;
+      this.anchorFullSnapshot = null;
+      this.maxDurationMs = Math.max(1e3, maxDurationMs);
+      this.maxFrames = Math.max(10, maxFrames);
+    }
+    push(frame, isCheckout = false) {
+      const ts = typeof frame.timestamp === "number" && Number.isFinite(frame.timestamp) ? frame.timestamp : Date.now();
+      const normalized = { ...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);
+    }
+    prune(now = Date.now()) {
+      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);
+      }
+    }
+    getSnapshotFrames() {
+      this.prune(
+        this.frames.length > 0 ? this.frames[this.frames.length - 1].timestamp : Date.now()
+      );
+      if (this.frames.length === 0) {
+        return [];
+      }
+      const result = [];
+      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;
+    }
+    clear() {
+      this.frames = [];
+      this.anchorMeta = null;
+      this.anchorFullSnapshot = null;
+    }
+    size() {
+      return this.frames.length;
+    }
+  };
+  var RageClickDetector = class {
+    constructor(windowMs = 1e3, threshold = 3, radiusPx = 30) {
+      this.clicks = [];
+      this.windowMs = windowMs;
+      this.threshold = threshold;
+      this.radiusPx = radiusPx;
+    }
+    recordClick(sample) {
+      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;
+        this.clicks = this.clicks.filter((c) => c.target !== sample.target);
+        return { isRageClick: true, clickCount: count };
+      }
+      return { isRageClick: false, clickCount: matching.length };
+    }
+    reset() {
+      this.clicks = [];
+    }
+  };
+  function isIgnoredNode(node, ignoreSelector) {
+    if (!node || typeof node !== "object") {
+      return false;
+    }
+    const candidate = node;
+    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;
+    }
+  }
+  var activeStopFn = null;
+  var activeRingBuffer = null;
+  var activeFlushFn = null;
+  async function flushSessionReplay(trigger = "manual") {
+    if (activeFlushFn) {
+      return activeFlushFn(trigger);
+    }
+    return null;
+  }
+  function stopSessionReplay() {
+    if (activeStopFn) {
+      activeStopFn();
+      activeStopFn = null;
+    }
+    activeRingBuffer = null;
+    activeFlushFn = null;
+  }
+  function startSessionReplay(options2 = {}, config3 = Configuration.getInstance()) {
+    stopSessionReplay();
+    const maxDurationMs = options2.maxDurationMs ?? 3e4;
+    const maxFrames = options2.maxFrames ?? 2e3;
+    const observeMutations = options2.observeMutations ?? true;
+    const detectRageClicks = options2.detectRageClicks ?? true;
+    const flushOnError = options2.flushOnError ?? true;
+    const ignoreSelector = options2.ignoreSelector ?? DEFAULT_IGNORE_SELECTOR;
+    const maskTextSelector = options2.maskTextSelector ?? DEFAULT_MASK_SELECTOR;
+    const maskAllInputs = options2.maskAllInputs ?? true;
+    const ringBuffer = new ReplayRingBuffer(maxDurationMs, maxFrames);
+    activeRingBuffer = ringBuffer;
+    let stopRecorder;
+    if (typeof options2.recorder === "function") {
+      stopRecorder = options2.recorder({
+        emit: (event, isCheckout) => {
+          ringBuffer.push(event, isCheckout);
+        },
+        checkoutEveryNms: maxDurationMs,
+        maskAllInputs,
+        blockSelector: ignoreSelector,
+        maskTextSelector
+      });
+    }
+    const doFlush = async (trigger) => {
+      const frames = ringBuffer.getSnapshotFrames();
+      if (frames.length === 0) {
+        return null;
+      }
+      const traceCtx = typeof config3.traceContext === "function" ? config3.traceContext() : null;
+      const envelope = {
+        logType: "replay",
+        type: "session_replay",
+        trigger,
+        sessionId: config3.sessionId,
+        httpSessionId: config3.httpSessionId,
+        browserSessionId: config3.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 options2.onFlush === "function") {
+        await options2.onFlush(envelope);
+        return envelope;
+      }
+      const serialized = JSON.stringify(envelope);
+      if (serialized.length <= MAX_KEEPALIVE_BYTES2) {
+        packageCustomLog(
+          {
+            logType: "replay",
+            type: "session_replay",
+            trigger,
+            frameCount: envelope.frameCount,
+            startTime: envelope.startTime,
+            endTime: envelope.endTime
+          },
+          () => ({
+            frames: envelope.frames
+          }),
+          false
+        );
+      } else if (config3.exporter && typeof config3.exporter.export === "function") {
+        try {
+          await config3.exporter.export([envelope], {
+            keepalive: false
+          });
+        } catch {
+        }
+      } else if (typeof fetch === "function") {
+        const targetUrl = config3.otlpEndpoint ? resolveOtlpLogsEndpoint(config3.otlpEndpoint) : config3.url;
+        if (targetUrl) {
+          try {
+            await fetch(targetUrl, {
+              method: "POST",
+              headers: buildHeaders(config3),
+              body: serialized,
+              keepalive: false
+            });
+          } catch {
+          }
+        }
+      }
+      return envelope;
+    };
+    activeFlushFn = doFlush;
+    let observer = null;
+    let mutationTimer = null;
+    let pendingAdded = 0;
+    let pendingRemoved = 0;
+    let pendingAttributes = 0;
+    const pendingTargets = /* @__PURE__ */ new Set();
+    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 (config3.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, config3.resolution || 500);
+          mutationTimer = setTimeout(flushMutationSummary, delay);
+        }
+      });
+      observer.observe(document.documentElement, {
+        childList: true,
+        subtree: true,
+        attributes: true,
+        attributeFilter: ["class", "id", "hidden", "disabled", "aria-expanded"]
+      });
+    }
+    const rageDetector = new RageClickDetector();
+    const onClickCapture = (e) => {
+      if (!config3.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 } : {}
+          }),
+          true
+        );
+        void doFlush("rage_click");
+      }
+    };
+    if (detectRageClicks && typeof document !== "undefined") {
+      document.addEventListener("click", onClickCapture, true);
+    }
+    const onWindowError = () => {
+      void doFlush("error");
+    };
+    const onUnhandledRejection = () => {
+      void doFlush("unhandledrejection");
+    };
+    if (flushOnError && typeof self !== "undefined" && typeof self.addEventListener === "function") {
+      self.addEventListener("error", onWindowError);
+      self.addEventListener("unhandledrejection", onUnhandledRejection);
+    }
+    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);
+        self.removeEventListener("unhandledrejection", onUnhandledRejection);
+      }
+      if (typeof stopRecorder === "function") {
+        stopRecorder();
+        stopRecorder = void 0;
+      }
+      rageDetector.reset();
+      ringBuffer.clear();
+    };
+    activeStopFn = cleanup;
+    return cleanup;
+  }
+  function getActiveReplayRingBuffer() {
+    return activeRingBuffer;
+  }
+
+  // src/main.ts
+  var config2 = Configuration.getInstance();
+  var logs2 = [];
+  var startLoadTimestamp = Date.now();
+  var endLoadTimestamp;
+  if (typeof self !== "undefined" && typeof self.addEventListener === "function") {
+    self.addEventListener("load", function() {
+      endLoadTimestamp = Date.now();
+    });
+  }
+  var started = false;
+  config2.update({
+    useraleVersion: version
+  });
+  initPackager(logs2, config2);
+  if (config2.autostart) {
+    setup(config2);
+  }
+  function setup(config3) {
+    if (!started) {
+      setTimeout(function() {
+        let state;
+        try {
+          state = document.readyState;
+        } catch (error) {
+          state = "complete";
+        }
+        if (config3.autostart && (state === "interactive" || state === "complete")) {
+          attachHandlers(config3);
+          initSender(logs2, config3);
+          started = config3.on = true;
+          if (config3.observeMutations && !getActiveReplayRingBuffer()) {
+            startSessionReplay({ observeMutations: true }, config3);
+          }
+          if (typeof window !== "undefined" && typeof document !== "undefined") {
+            const loadDuration = Math.max(
+              1,
+              (endLoadTimestamp ?? Date.now()) - startLoadTimestamp
+            );
+            packageCustomLog(
+              {
+                type: "load",
+                details: { pageLoadTime: loadDuration }
+              },
+              () => ({}),
+              false
+            );
+          }
+        } else {
+          setup(config3);
+        }
+      }, 100);
+    }
+  }
+  var version2 = version;
+  function start() {
+    if (!started || config2.autostart === false) {
+      const wasStarted = started;
+      started = config2.on = true;
+      config2.update({ autostart: true });
+      if (!wasStarted) {
+        attachHandlers(config2);
+        initSender(logs2, config2);
+        if (config2.observeMutations && !getActiveReplayRingBuffer()) {
+          startSessionReplay({ observeMutations: true }, config2);
+        }
+      }
+    }
+  }
+  function stop() {
+    started = config2.on = false;
+    config2.update({ autostart: false });
+    stopSessionReplay();
+  }
+  function options(newConfig) {
+    if (newConfig) {
+      config2.update(newConfig);
+      if (started && newConfig.observeMutations !== void 0) {
+        if (newConfig.observeMutations && !getActiveReplayRingBuffer()) {
+          startSessionReplay({ observeMutations: true }, config2);
+        } else if (!newConfig.observeMutations && getActiveReplayRingBuffer()) {
+          stopSessionReplay();
+        }
+      }
+    }
+    return config2;
+  }
+  function log(customLog) {
+    if (customLog) {
+      logs2.push(customLog);
+      return true;
+    } else {
+      return false;
+    }
+  }
+  if (typeof window !== "undefined") {
+    window.userale = {
+      start,
+      stop,
+      options,
+      log,
+      version,
+      details: defineCustomDetails,
+      registerAuthCallback,
+      registerHeadersCallback,
+      registerExporter,
+      startSessionReplay,
+      stopSessionReplay,
+      flushSessionReplay,
+      getTargetRect,
+      toOtlpExportLogsServiceRequest,
+      toOtlpLogRecord,
+      toProtoLogBatch,
+      toProtoUserALELog,
+      addCallbacks,
+      removeCallbacks,
+      packageLog,
+      packageCustomLog,
+      getSelector,
+      buildPath,
+      detectBrowser
+    };
+  }
+})();
+/*!
+ * 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.
+ */
+/*!
+ * 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.
+ */
+//# sourceMappingURL=main.global.js.map
\ No newline at end of file