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 & 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 & 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 & 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 & 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: '© <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, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>');
+ }
+
+ 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: '© <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