diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..251f5ca
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,18 @@
+name: ci
+on:
+ push:
+ branches: [main, next]
+ pull_request:
+ branches: ['*']
+
+jobs:
+ tests:
+ name: 'Tests'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20.x
+ - run: npm install
+ - run: npm run test
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..1717850
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,21 @@
+name: Publish Package to npmjs
+on:
+ release:
+ types: [published]
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - uses: actions/checkout@v4
+ # Setup .npmrc file to publish to npm
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.x'
+ registry-url: 'https://registry.npmjs.org'
+ - run: npm install
+ - run: npm publish --provenance --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..89d14e5
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,3 @@
+npm run build
+git add dist
+git add dist_bundle
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..022ef3e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,209 @@
+# ALTCHA Analytics Tracker
+
+This repository contains the front-end library for collecting analytics data with ALTCHA Analytics.
+
+## Why ALTCHA Analytics?
+
+ALTCHA Analytics provides a privacy-first, GDPR-compliant alternative to traditional analytics platforms like Google Analytics. Unlike most other services, it operates without cookies or fingerprinting, ensuring that all data is anonymized by default.
+
+**Key Features**:
+- Compliant with GDPR, CCPA, and PECR regulations
+- No cookies or fingerprinting required
+- Flexible: Use for web, app, or API analytics
+- Customizable event tracking and properties
+- Lightweight (~3.5kB gzipped)
+
+## Installation & Usage
+
+You can integrate ALTCHA Analytics in two ways:
+
+1. **Module** (Recommended for modern frameworks such as React, Vue, Angular)
+2. **Script Tag** (Simple HTML inclusion)
+
+### 1. Module Installation (Preferred for Frameworks)
+
+Install via npm:
+
+```bash
+npm install @altcha/tracker
+```
+
+Initialize the tracker in your app:
+
+```javascript
+import { Tracker } from '@altcha/tracker';
+
+const tracker = new Tracker({
+ projectId: 'pro_...',
+});
+```
+
+### 2. Script Tag Integration
+
+Simply add the following snippet to your HTML:
+
+```html
+
+```
+
+Make sure to replace `"pro_..."` with your unique `projectId`.
+
+## Configuration
+
+The `Tracker` class constructor accepts the following configuration options:
+
+- **`allowSearchParams?: string[]`** – By default, the script removes all query parameters (those after `?` in the URL). Use this option to whitelist specific parameters that should be tracked.
+- **`apiUrl?: string`** – Override the default API URL for reporting events.
+- **`appVersion?: string`** – Track the version of your application (max 12 characters).
+- **`click?: IBaseExtensionOptions | boolean`** – Disable or configure the `click` extension (see below for details).
+- **`cookie?: ICookieExtensionOptions | boolean`** – Disable or configure the `cookie` extension.
+- **`debug?: boolean`** – Enable debug mode for logging.
+- **`globalName?: string | null | false`** – Override the default global variable name for the Tracker instance. Set to `null` to skip global registration.
+- **`hash?: IBaseExtensionOptions | boolean`** – Disable or configure the `hash` extension.
+- **`keyboard?: IBaseExtensionOptions | boolean`** – Disable or configure the `keyboard` extension.
+- **`mouse?: IBaseExtensionOptions | boolean`** – Disable or configure the `mouse` extension.
+- **`projectId: string`** – Required ALTCHA project ID (format: `pro_{unique_id}`).
+- **`pushstate?: IBaseExtensionOptions | boolean`** – Disable or configure the `pushstate` extension.
+- **`respectDnt?: boolean`** – When `true`, the tracker will not report any events if the user's browser is configured with `Do Not Track` or `globalPrivacyControl`.
+- **`uniqueId?: string`** – Provide the user's unique ID, if applicable, to track returning visitors.
+- **`visibility?: IVisibilityExtensionOptions | boolean`** – Disable or configure the `visibility` extension.
+
+These options can also be provided as attributes in the `
+```
+
+## Extensions
+
+Tracking features are provided through "extensions," which can be individually enabled or disabled depending on your needs and privacy concerns.
+
+### Click
+
+Enabled by default.
+
+Tracks user mouse/pointer interactions, detecting exit events and outbound links.
+
+### Cookie
+
+Disabled by default.
+
+The `cookie` extension tracks returning visitors by setting a small cookie (`_altcha_visited=1`) that expires in 30 days.
+
+You can configure this extension with the following options:
+
+```js
+new Tracker({
+ projectId: '...',
+ cookie: {
+ cookieExpireDays: 30, // Cookie expiration in days
+ cookieName: '_altcha_visited', // Cookie name
+ cookiePath: '/', // Cookie path (defaults to '/')
+ }
+})
+```
+
+Note: Enabling this extension may require user consent under GDPR.
+
+### Hash
+
+Disabled by default.
+
+Tracks the `#hash` part of the URL when using hash-based routing in your application.
+
+### Keyboard
+
+Enabled by default.
+
+Detects exit events triggered by keyboard shortcuts (e.g., closing the tab).
+
+### Mouse
+
+Enabled by default.
+
+Detects exit events triggered by pointer (e.g., closing the tab using the mouse).
+
+### PushState
+
+Enabled by default.
+
+Automatically detects pageviews when `history.pushState()` is called. If disabled, use `.trackPageview()` or `.trackEvent()` to manually report events.
+
+### Visibility
+
+Enabled by default.
+
+Tracks exit events when the tab/window is hidden during page unload.
+
+## Implementation Details
+
+This script reports collected events in bulk when the page unloads, using the [`sendBeacon()`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon) function. You can configure the API endpoint via the `apiUrl` option.
+
+### Exit Events
+
+Exit events are reported when the user leaves the website (e.g., by closing the tab or navigating elsewhere).
+To track these events accurately, ensure the following extensions are enabled: `click`, `keyboard`, `mouse`, `visibility`. Disabling these will reduce the accuracy of visit duration data.
+
+### Respect for Privacy (Do Not Track)
+
+If you respect users' privacy preferences and want to disable tracking when `Do Not Track` is enabled in the browser, you can set the `respectDnt` option to `true`.
+
+```javascript
+new Tracker({
+ projectId: '...',
+ respectDnt: true, // disable tracking for users with Do Not Track enabled
+});
+```
+
+### Debug Mode
+
+Enabling `debug` mode logs additional information to the browser console, which is helpful for development purposes.
+
+```javascript
+new Tracker({
+ projectId: '...',
+ debug: true, // enable debug logging
+});
+```
+
+## API Reference
+
+The following is a quick overview of the main methods available in the ALTCHA Analytics tracker:
+
+- `trackPageview(event: IEvent, unload: boolean = false)`: Track page views.
+- `trackEvent(event: IEvent, unload: boolean = false)`: Track custom events.
+- `destroy()`: Destroys the tracker instance.
+
+### Types
+
+For TypeScript types and interfaces, see [/src/types.ts](/src/types.ts).
+
+
+## License
+
+ALTCHA Analytics is licensed under the MIT License. See the `LICENSE` file for more details.
diff --git a/dist/tracker.js b/dist/tracker.js
new file mode 100644
index 0000000..9b93bba
--- /dev/null
+++ b/dist/tracker.js
@@ -0,0 +1,470 @@
+var U = Object.defineProperty;
+var T = (s) => {
+ throw TypeError(s);
+};
+var _ = (s, e, t) => e in s ? U(s, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : s[e] = t;
+var o = (s, e, t) => _(s, typeof e != "symbol" ? e + "" : e, t), N = (s, e, t) => e.has(s) || T("Cannot " + t);
+var n = (s, e, t) => (N(s, e, "read from private field"), t ? t.call(s) : e.get(s)), a = (s, e, t) => e.has(s) ? T("Cannot add the same private member more than once") : e instanceof WeakSet ? e.add(s) : e.set(s, t), l = (s, e, t, i) => (N(s, e, "write to private field"), i ? i.call(s, t) : e.set(s, t), t), A = (s, e, t) => (N(s, e, "access private method"), t);
+class d {
+ constructor(e, t = { disable: !1 }) {
+ o(this, "lastEvent", null);
+ this.tracker = e, this.options = t;
+ }
+ destroy() {
+ }
+ getExitReason(e = !1) {
+ }
+ isLastEventRecent(e = 1e4, t = this.lastEvent) {
+ return !!t && performance.now() - t.timeStamp < e;
+ }
+}
+var E;
+class I extends d {
+ constructor(t, i) {
+ super(t);
+ a(this, E, this.onClick.bind(this));
+ addEventListener("click", n(this, E), {
+ capture: !0
+ });
+ }
+ destroy() {
+ removeEventListener("click", n(this, E));
+ }
+ getExitReason() {
+ if (this.lastEvent && this.isLastEventRecent()) {
+ const t = this.lastEvent.target, i = (t == null ? void 0 : t.getAttribute("data-link")) || (t == null ? void 0 : t.getAttribute("href"));
+ if (i) {
+ const r = new URL(i, location.origin);
+ if (r.hostname && r.hostname !== location.hostname)
+ return this.tracker.sanitizeUrl(r);
+ }
+ }
+ }
+ onClick(t) {
+ const i = t.target;
+ i && i.tagName === "A" && (this.lastEvent = t);
+ }
+}
+E = new WeakMap();
+var u, f, p;
+class V extends d {
+ constructor(t, i) {
+ super(t);
+ a(this, u);
+ a(this, f);
+ a(this, p);
+ l(this, u, i.cookieName || "_altcha_visited"), l(this, f, i.cookieExpireDays || 30), l(this, p, i.cookiePath || "/"), this.getCookie(n(this, u)) === "1" && (this.tracker.returningVisitor = !0), this.setCookie(
+ n(this, u),
+ "1",
+ new Date(Date.now() + 864e5 * n(this, f))
+ );
+ }
+ destroy() {
+ }
+ getCookie(t) {
+ const i = document.cookie.split(/;\s?/);
+ for (const r of i)
+ if (r.startsWith(t + "="))
+ return r.slice(t.length + 1);
+ return null;
+ }
+ setCookie(t, i, r) {
+ document.cookie = t + "=" + i + "; expires=" + r.toUTCString() + `; SameSite=Strict; path=${n(this, p) || "/"}`;
+ }
+}
+u = new WeakMap(), f = new WeakMap(), p = new WeakMap();
+var m;
+class X extends d {
+ constructor(t, i) {
+ super(t);
+ a(this, m, this.onHashChange.bind(this));
+ addEventListener("hashchange", n(this, m));
+ }
+ destroy() {
+ removeEventListener("hashchange", n(this, m));
+ }
+ onHashChange() {
+ this.tracker.trackPageview();
+ }
+}
+m = new WeakMap();
+var b;
+class H extends d {
+ constructor(t, i) {
+ super(t);
+ a(this, b, this.onKeyDown.bind(this));
+ addEventListener("keydown", n(this, b));
+ }
+ destroy() {
+ removeEventListener("keydown", n(this, b));
+ }
+ isLastKeyboardEventCtrl() {
+ return !!this.lastEvent && (this.lastEvent.ctrlKey || this.lastEvent.metaKey);
+ }
+ getExitReason(t = !1) {
+ if (t && this.isLastEventRecent(100) && this.isLastKeyboardEventCtrl())
+ return "";
+ }
+ onKeyDown(t) {
+ this.lastEvent = t;
+ }
+}
+b = new WeakMap();
+var k, y;
+class B extends d {
+ constructor(t, i) {
+ super(t);
+ a(this, k, this.onMouseEnter.bind(this));
+ a(this, y, this.onMouseLeave.bind(this));
+ o(this, "isMouseOut", !1);
+ o(this, "offsetX", -1);
+ o(this, "offsetY", -1);
+ document.body.addEventListener("mouseleave", n(this, y)), document.body.addEventListener("mouseenter", n(this, k));
+ }
+ destroy() {
+ document.body.removeEventListener("mouseleave", n(this, y)), document.body.removeEventListener("mouseenter", n(this, k));
+ }
+ getExitReason() {
+ if (this.isMouseOut) {
+ const t = this.tracker.getExtension("pushstate");
+ return t && t.lastPopStateEvent && t.isLastEventRecent(100, t.lastPopStateEvent) || this.offsetX >= 0 && this.offsetX >= 0 && this.offsetX < 150 ? void 0 : "";
+ }
+ }
+ onMouseEnter() {
+ this.isMouseOut = !1, this.offsetX = -1, this.offsetY = -1;
+ }
+ onMouseLeave(t) {
+ this.isMouseOut = !0, this.offsetX = t.clientX, this.offsetY = t.clientY;
+ }
+}
+k = new WeakMap(), y = new WeakMap();
+var v, x, L;
+class K extends d {
+ constructor(t, i) {
+ super(t);
+ a(this, v, null);
+ a(this, x, this.onPopState.bind(this));
+ a(this, L, this.onPushState.bind(this));
+ o(this, "lastPopStateEvent", null);
+ const r = l(this, v, history.pushState);
+ history.pushState = (D, M, O) => {
+ n(this, L).call(this), r == null || r.apply(history, [D, M, O]);
+ }, addEventListener("popstate", n(this, x));
+ }
+ destroy() {
+ n(this, v) && (history.pushState = n(this, v)), removeEventListener("popstate", n(this, x));
+ }
+ onPopState(t) {
+ this.lastPopStateEvent = t, this.tracker.trackPageview();
+ }
+ onPushState() {
+ this.tracker.trackPageview();
+ }
+}
+v = new WeakMap(), x = new WeakMap(), L = new WeakMap();
+var w, P, g;
+class F extends d {
+ constructor(t, i) {
+ super(t);
+ a(this, w, this.onVisibilityChange.bind(this));
+ a(this, P);
+ a(this, g, null);
+ o(this, "visibilityState", document.visibilityState);
+ l(this, P, i.hiddenTimeout || 4e3), addEventListener("visibilitychange", n(this, w));
+ }
+ destroy() {
+ removeEventListener("visibilitychange", n(this, w));
+ }
+ getExitReason(t = !1) {
+ if (t && this.visibilityState === "hidden" && this.lastEvent && performance.now() - this.lastEvent.timeStamp >= 1e3)
+ return "";
+ }
+ onTimeout() {
+ document.visibilityState === "hidden" && this.tracker.trackPageview({}, !0);
+ }
+ onVisibilityChange(t) {
+ this.lastEvent = t, this.visibilityState = document.visibilityState, this.tracker.isMobile && (n(this, g) && clearTimeout(n(this, g)), document.visibilityState === "hidden" && l(this, g, setTimeout(() => {
+ this.onTimeout();
+ }, n(this, P))));
+ }
+}
+w = new WeakMap(), P = new WeakMap(), g = new WeakMap();
+var c, S, R;
+const h = class h {
+ /**
+ * Constructor to initialize the Tracker instance.
+ *
+ * @param options - Configuration options for the tracker, including project ID, API URL, and extension settings.
+ */
+ constructor(e) {
+ a(this, S);
+ // Bound method to handle the 'pagehide' event
+ a(this, c, this.onPageHide.bind(this));
+ // Boolean flag indicating if the user is on a mobile device
+ o(this, "isMobile", /Mobi|Android|iPhone|iPad|iPod|Opera Mini/i.test(
+ navigator.userAgent || ""
+ ));
+ // Array to store tracked events
+ o(this, "events", []);
+ // Object to store initialized extensions
+ o(this, "extensions", {});
+ // The global name registered for the tracker instance, or null if none is registered
+ o(this, "globalName", null);
+ // Timestamp of the last page load
+ o(this, "lastPageLoadAt", performance.now());
+ // Last recorded pageview URL
+ o(this, "lastPageview", null);
+ // Boolean flag indicating if the visitor is returning
+ o(this, "returningVisitor", null);
+ // Number of events tracked
+ o(this, "trackedEvents", 0);
+ // Number of pageviews tracked
+ o(this, "trackedPageviews", 0);
+ if (this.options = e, !e.projectId)
+ throw new Error("Parameter projectId required.");
+ A(this, S, R).call(this), this.isDNTEnabled && this.options.respectDnt === !0 ? this.log("DoNotTrack enabled.") : (this.loadExtensions(), addEventListener("pagehide", n(this, c)), addEventListener("beforeunload", n(this, c)));
+ }
+ /**
+ * Getter for the API URL. Returns the user-provided API URL or the default one.
+ */
+ get apiUrl() {
+ return this.options.apiUrl || h.DEFAULT_API_URL;
+ }
+ /**
+ * Getter to determine if "Do Not Track" (DNT) is enabled in the user's browser.
+ */
+ get isDNTEnabled() {
+ return "doNotTrack" in navigator && navigator.doNotTrack === "1" || "globalPrivacyControl" in navigator && navigator.globalPrivacyControl === !0;
+ }
+ /**
+ * Destroys the tracker instance, removing all listeners and extensions.
+ */
+ destroy() {
+ this.flushEvents();
+ for (const e in this.extensions)
+ this.extensions[e].destroy();
+ this.extensions = {}, removeEventListener("pagehide", n(this, c)), removeEventListener("beforeunload", n(this, c)), this.globalName && delete globalThis[this.globalName];
+ }
+ /**
+ * Flushes all collected events by sending them to the API.
+ */
+ flushEvents() {
+ const e = this.events.splice(0);
+ e.length && this.sendBeacon(e);
+ }
+ /**
+ * Builds the payload for sending events to the API.
+ *
+ * @param events - List of events to be sent.
+ * @returns The payload object containing events, project ID, timestamp, and unique ID.
+ */
+ getBeaconPayload(e) {
+ return {
+ events: e,
+ projectId: this.options.projectId,
+ time: Date.now(),
+ uniqueId: this.options.uniqueId
+ };
+ }
+ /**
+ * Determines the reason for the user's exit from the page.
+ *
+ * @param unload - Boolean indicating if the reason is triggered by a page unload.
+ * @returns The exit reason, if any, provided by the extensions.
+ */
+ getExitReason(e = !1) {
+ for (const t in this.extensions) {
+ const i = this.extensions[t].getExitReason(e);
+ if (i !== void 0)
+ return this.log("exit reason:", { ext: t, result: i }), i;
+ }
+ }
+ /**
+ * Retrieves an extension by name.
+ *
+ * @param name - The name of the extension to retrieve.
+ * @returns The extension instance.
+ */
+ getExtension(e) {
+ return this.extensions[e];
+ }
+ /**
+ * Returns the current page's hostname.
+ */
+ getHostname() {
+ return location.hostname;
+ }
+ /**
+ * Returns the current page's origin.
+ */
+ getOrigin() {
+ return location.origin;
+ }
+ /**
+ * Returns the sanitized URL of the current page, excluding unwanted query parameters.
+ */
+ getView() {
+ return this.sanitizeUrl(location.href);
+ }
+ /**
+ * Constructs the options for a pageview event.
+ *
+ * @param unload - Boolean indicating if the pageview is triggered by a page unload.
+ * @returns An object containing pageview event details such as duration, referrer, and exit reason.
+ */
+ getPageviewOptions(e = !1) {
+ const t = this.getExitReason(e), i = this.getReferrer(), r = this.getOrigin();
+ return {
+ appVersion: this.options.appVersion,
+ exit: t !== void 0,
+ outbound: t,
+ duration: Math.max(0, Math.floor(performance.now() - this.lastPageLoadAt)),
+ referrer: i && new URL(i, r).origin === r ? "" : i,
+ returning: this.returningVisitor === null ? void 0 : this.returningVisitor,
+ view: this.getView()
+ };
+ }
+ /**
+ * Retrieves the document's referrer URL.
+ */
+ getReferrer() {
+ return document.referrer;
+ }
+ /**
+ * Checks if a particular extension is loaded.
+ *
+ * @param name - The name of the extension.
+ * @returns True if the extension is loaded, false otherwise.
+ */
+ hasExtension(e) {
+ return !!this.getExtension(e);
+ }
+ /**
+ * Loads all active extensions based on the tracker options and default settings.
+ */
+ loadExtensions() {
+ var e;
+ for (const t in h.EXTENSIONS) {
+ let i = ((e = this.options) == null ? void 0 : e[t]) !== void 0 ? this.options[t] : {};
+ typeof i == "boolean" ? i = {
+ disable: !i
+ } : i.disable = i.disable === void 0 ? !h.DEFAULT_EXTENSIONS.includes(t) : i.disable, i.disable !== !0 && (this.extensions[t] = new h.EXTENSIONS[t](
+ this,
+ i
+ ));
+ }
+ }
+ /**
+ * Logs a message to the console if debug mode is enabled.
+ *
+ * @param args - The message or data to log.
+ */
+ log(...e) {
+ this.options.debug && console.log("[ALTCHA Tracker]", ...e);
+ }
+ /**
+ * Removes query parameters and fragments from the URL based on the tracker settings.
+ *
+ * @param href - The full URL to be sanitized.
+ * @returns The sanitized URL.
+ */
+ sanitizeUrl(e) {
+ var t;
+ if (e = new URL(e), (t = this.options.allowSearchParams) != null && t.length && e.hostname === this.getHostname())
+ for (const [i] of e.searchParams)
+ this.options.allowSearchParams.includes(i) || e.searchParams.delete(i);
+ else
+ e.search = "";
+ return this.hasExtension("hash") || (e.hash = ""), e.toString();
+ }
+ /**
+ * Sends the collected events to the server using the Beacon API.
+ *
+ * @param events - The list of events to send.
+ */
+ sendBeacon(e) {
+ if ("sendBeacon" in navigator)
+ return navigator.sendBeacon(this.apiUrl, JSON.stringify(this.getBeaconPayload(e)));
+ }
+ /**
+ * Tracks a custom event with optional parameters.
+ *
+ * @param {Partial} options - Optional event details to customize the tracked event. Any properties in the IEvent interface can be passed here. Defaults to an empty object.
+ * @param {boolean} [unload=false] - If set to true, the event is tracked during the page unload (exit). This ensures that the event is reported before the user leaves the page.
+ *
+ * @returns {boolean} - Returns true when the event has been successfully tracked.
+ *
+ * @remarks
+ * The method merges the provided options with the current timestamp to form the event. It pushes the event to the internal event queue and logs the event. If the `unload` flag is true, the events are flushed right away to ensure they are captured before the user navigates away or closes the page.
+ */
+ trackEvent(e = {}, t = !1) {
+ const i = {
+ timestamp: Date.now(),
+ ...e
+ };
+ return this.events.push(i), this.trackedEvents += 1, this.log("trackEvent", i), t && this.flushEvents(), !0;
+ }
+ /**
+ * Tracks a pageview event and handles duplicate pageviews and page load timing.
+ *
+ * @param {Partial} options - Additional pageview details. Any properties in the IEvent interface can be passed here. Defaults to an empty object.
+ * @param {boolean} [unload=false] - If true, the pageview event is tracked during the page unload (exit), ensuring it is reported before the user leaves the page.
+ *
+ * @returns {boolean} - Returns true if the pageview was successfully tracked. Returns false if the pageview is detected as a duplicate.
+ *
+ * @remarks
+ * The method generates pageview-specific options (like view duration) and checks if the pageview is a duplicate (i.e., a pageview of the same page within a very short time frame). If the pageview is not a duplicate, it logs and tracks the event. The `unload` flag ensures that the pageview is reported before the user exits the page or navigates away, by flushing the events queue if necessary. Additionally, the method tracks pageview counts, adjusts state based on whether the user exited the page, and updates the timestamp of the last pageview.
+ */
+ trackPageview(e = {}, t = !1) {
+ const i = this.getPageviewOptions(t);
+ return this.events.length && i.duration < 100 && this.events[this.events.length - 1].view === i.view ? (this.log("duplicate pageview", i), !1) : (this.log("trackPageview", i), this.trackEvent(
+ {
+ ...i,
+ ...e
+ },
+ t
+ ), this.trackedPageviews += 1, this.lastPageLoadAt = performance.now(), this.lastPageview = i.view, i.exit && (this.trackedPageviews = 0), !0);
+ }
+ /**
+ * Handles the pagehide event, typically used to send any unsent events before the user leaves the page.
+ */
+ onPageHide() {
+ this.lastPageview !== this.getView() && this.trackPageview({}, !0);
+ }
+};
+c = new WeakMap(), S = new WeakSet(), /**
+ * Registers the global name for the tracker instance.
+ *
+ * @returns True if the global name was registered, false otherwise.
+ */
+R = function() {
+ if (this.globalName = this.options.globalName === void 0 ? h.DEAFAUL_GLOBAL_NAME : this.options.globalName || null, this.globalName) {
+ if (globalThis[this.globalName])
+ throw new Error(
+ "Another instance of the Tracker is already present in globalThis. Set globalName:null to disable global reference."
+ );
+ globalThis[this.globalName] = this;
+ }
+}, // Static property containing all available extensions
+o(h, "EXTENSIONS", {
+ click: I,
+ cookie: V,
+ hash: X,
+ keyboard: H,
+ mouse: B,
+ pushstate: K,
+ visibility: F
+}), // Default API endpoint for sending events
+o(h, "DEFAULT_API_URL", "https://eu.altcha.org/api/v1/event"), // Default set of enabled extensions when initializing the tracker
+o(h, "DEFAULT_EXTENSIONS", [
+ "click",
+ "keyboard",
+ "mouse",
+ "pushstate",
+ "visibility"
+]), // Default global name for the tracker instance
+o(h, "DEAFAUL_GLOBAL_NAME", "altchaTracker");
+let C = h;
+export {
+ C as Tracker
+};
diff --git a/dist/tracker.umd.cjs b/dist/tracker.umd.cjs
new file mode 100644
index 0000000..9a23c15
--- /dev/null
+++ b/dist/tracker.umd.cjs
@@ -0,0 +1 @@
+(function(n,s){typeof exports=="object"&&typeof module<"u"?s(exports):typeof define=="function"&&define.amd?define(["exports"],s):(n=typeof globalThis<"u"?globalThis:n||self,s(n.AltchaAnalyticsTracker={}))})(this,function(n){"use strict";var j=Object.defineProperty;var O=n=>{throw TypeError(n)};var F=(n,s,r)=>s in n?j(n,s,{enumerable:!0,configurable:!0,writable:!0,value:r}):n[s]=r;var a=(n,s,r)=>F(n,typeof s!="symbol"?s+"":s,r),M=(n,s,r)=>s.has(n)||O("Cannot "+r);var o=(n,s,r)=>(M(n,s,"read from private field"),r?r.call(n):s.get(n)),h=(n,s,r)=>s.has(n)?O("Cannot add the same private member more than once"):s instanceof WeakSet?s.add(n):s.set(n,r),v=(n,s,r,N)=>(M(n,s,"write to private field"),N?N.call(n,r):s.set(n,r),r),R=(n,s,r)=>(M(n,s,"access private method"),r);var p,g,m,b,y,k,x,P,f,w,T,L,S,E,d,A,D;class s{constructor(i,t={disable:!1}){a(this,"lastEvent",null);this.tracker=i,this.options=t}destroy(){}getExitReason(i=!1){}isLastEventRecent(i=1e4,t=this.lastEvent){return!!t&&performance.now()-t.timeStamp=0&&this.offsetX>=0&&this.offsetX<150?void 0:""}}onMouseEnter(){this.isMouseOut=!1,this.offsetX=-1,this.offsetY=-1}onMouseLeave(t){this.isMouseOut=!0,this.offsetX=t.clientX,this.offsetY=t.clientY}}x=new WeakMap,P=new WeakMap;class V extends s{constructor(t,e){super(t);h(this,f,null);h(this,w,this.onPopState.bind(this));h(this,T,this.onPushState.bind(this));a(this,"lastPopStateEvent",null);const l=v(this,f,history.pushState);history.pushState=(H,B,K)=>{o(this,T).call(this),l==null||l.apply(history,[H,B,K])},addEventListener("popstate",o(this,w))}destroy(){o(this,f)&&(history.pushState=o(this,f)),removeEventListener("popstate",o(this,w))}onPopState(t){this.lastPopStateEvent=t,this.tracker.trackPageview()}onPushState(){this.tracker.trackPageview()}}f=new WeakMap,w=new WeakMap,T=new WeakMap;class X extends s{constructor(t,e){super(t);h(this,L,this.onVisibilityChange.bind(this));h(this,S);h(this,E,null);a(this,"visibilityState",document.visibilityState);v(this,S,e.hiddenTimeout||4e3),addEventListener("visibilitychange",o(this,L))}destroy(){removeEventListener("visibilitychange",o(this,L))}getExitReason(t=!1){if(t&&this.visibilityState==="hidden"&&this.lastEvent&&performance.now()-this.lastEvent.timeStamp>=1e3)return""}onTimeout(){document.visibilityState==="hidden"&&this.tracker.trackPageview({},!0)}onVisibilityChange(t){this.lastEvent=t,this.visibilityState=document.visibilityState,this.tracker.isMobile&&(o(this,E)&&clearTimeout(o(this,E)),document.visibilityState==="hidden"&&v(this,E,setTimeout(()=>{this.onTimeout()},o(this,S))))}}L=new WeakMap,S=new WeakMap,E=new WeakMap;const c=class c{constructor(i){h(this,A);h(this,d,this.onPageHide.bind(this));a(this,"isMobile",/Mobi|Android|iPhone|iPad|iPod|Opera Mini/i.test(navigator.userAgent||""));a(this,"events",[]);a(this,"extensions",{});a(this,"globalName",null);a(this,"lastPageLoadAt",performance.now());a(this,"lastPageview",null);a(this,"returningVisitor",null);a(this,"trackedEvents",0);a(this,"trackedPageviews",0);if(this.options=i,!i.projectId)throw new Error("Parameter projectId required.");R(this,A,D).call(this),this.isDNTEnabled&&this.options.respectDnt===!0?this.log("DoNotTrack enabled."):(this.loadExtensions(),addEventListener("pagehide",o(this,d)),addEventListener("beforeunload",o(this,d)))}get apiUrl(){return this.options.apiUrl||c.DEFAULT_API_URL}get isDNTEnabled(){return"doNotTrack"in navigator&&navigator.doNotTrack==="1"||"globalPrivacyControl"in navigator&&navigator.globalPrivacyControl===!0}destroy(){this.flushEvents();for(const i in this.extensions)this.extensions[i].destroy();this.extensions={},removeEventListener("pagehide",o(this,d)),removeEventListener("beforeunload",o(this,d)),this.globalName&&delete globalThis[this.globalName]}flushEvents(){const i=this.events.splice(0);i.length&&this.sendBeacon(i)}getBeaconPayload(i){return{events:i,projectId:this.options.projectId,time:Date.now(),uniqueId:this.options.uniqueId}}getExitReason(i=!1){for(const t in this.extensions){const e=this.extensions[t].getExitReason(i);if(e!==void 0)return this.log("exit reason:",{ext:t,result:e}),e}}getExtension(i){return this.extensions[i]}getHostname(){return location.hostname}getOrigin(){return location.origin}getView(){return this.sanitizeUrl(location.href)}getPageviewOptions(i=!1){const t=this.getExitReason(i),e=this.getReferrer(),l=this.getOrigin();return{appVersion:this.options.appVersion,exit:t!==void 0,outbound:t,duration:Math.max(0,Math.floor(performance.now()-this.lastPageLoadAt)),referrer:e&&new URL(e,l).origin===l?"":e,returning:this.returningVisitor===null?void 0:this.returningVisitor,view:this.getView()}}getReferrer(){return document.referrer}hasExtension(i){return!!this.getExtension(i)}loadExtensions(){var i;for(const t in c.EXTENSIONS){let e=((i=this.options)==null?void 0:i[t])!==void 0?this.options[t]:{};typeof e=="boolean"?e={disable:!e}:e.disable=e.disable===void 0?!c.DEFAULT_EXTENSIONS.includes(t):e.disable,e.disable!==!0&&(this.extensions[t]=new c.EXTENSIONS[t](this,e))}}log(...i){this.options.debug&&console.log("[ALTCHA Tracker]",...i)}sanitizeUrl(i){var t;if(i=new URL(i),(t=this.options.allowSearchParams)!=null&&t.length&&i.hostname===this.getHostname())for(const[e]of i.searchParams)this.options.allowSearchParams.includes(e)||i.searchParams.delete(e);else i.search="";return this.hasExtension("hash")||(i.hash=""),i.toString()}sendBeacon(i){if("sendBeacon"in navigator)return navigator.sendBeacon(this.apiUrl,JSON.stringify(this.getBeaconPayload(i)))}trackEvent(i={},t=!1){const e={timestamp:Date.now(),...i};return this.events.push(e),this.trackedEvents+=1,this.log("trackEvent",e),t&&this.flushEvents(),!0}trackPageview(i={},t=!1){const e=this.getPageviewOptions(t);return this.events.length&&e.duration<100&&this.events[this.events.length-1].view===e.view?(this.log("duplicate pageview",e),!1):(this.log("trackPageview",e),this.trackEvent({...e,...i},t),this.trackedPageviews+=1,this.lastPageLoadAt=performance.now(),this.lastPageview=e.view,e.exit&&(this.trackedPageviews=0),!0)}onPageHide(){this.lastPageview!==this.getView()&&this.trackPageview({},!0)}};d=new WeakMap,A=new WeakSet,D=function(){if(this.globalName=this.options.globalName===void 0?c.DEAFAUL_GLOBAL_NAME:this.options.globalName||null,this.globalName){if(globalThis[this.globalName])throw new Error("Another instance of the Tracker is already present in globalThis. Set globalName:null to disable global reference.");globalThis[this.globalName]=this}},a(c,"EXTENSIONS",{click:r,cookie:N,hash:U,keyboard:_,mouse:I,pushstate:V,visibility:X}),a(c,"DEFAULT_API_URL","https://eu.altcha.org/api/v1/event"),a(c,"DEFAULT_EXTENSIONS",["click","keyboard","mouse","pushstate","visibility"]),a(c,"DEAFAUL_GLOBAL_NAME","altchaTracker");let C=c;n.Tracker=C,Object.defineProperty(n,Symbol.toStringTag,{value:"Module"})});
diff --git a/dist_bundle/tracker.js b/dist_bundle/tracker.js
new file mode 100644
index 0000000..6ed0e30
--- /dev/null
+++ b/dist_bundle/tracker.js
@@ -0,0 +1 @@
+(function(n,s){typeof exports=="object"&&typeof module<"u"?s(exports):typeof define=="function"&&define.amd?define(["exports"],s):(n=typeof globalThis<"u"?globalThis:n||self,s(n.AltchaAnalyticsTracker={}))})(this,function(n){"use strict";var W=Object.defineProperty;var I=n=>{throw TypeError(n)};var $=(n,s,r)=>s in n?W(n,s,{enumerable:!0,configurable:!0,writable:!0,value:r}):n[s]=r;var a=(n,s,r)=>$(n,typeof s!="symbol"?s+"":s,r),R=(n,s,r)=>s.has(n)||I("Cannot "+r);var o=(n,s,r)=>(R(n,s,"read from private field"),r?r.call(n):s.get(n)),l=(n,s,r)=>s.has(n)?I("Cannot add the same private member more than once"):s instanceof WeakSet?s.add(n):s.set(n,r),g=(n,s,r,A)=>(R(n,s,"write to private field"),A?A.call(n,r):s.set(n,r),r),V=(n,s,r)=>(R(n,s,"access private method"),r);var m,f,b,y,k,x,w,P,E,L,T,S,N,p,v,C,X,D,_;class s{constructor(i,t={disable:!1}){a(this,"lastEvent",null);this.tracker=i,this.options=t}destroy(){}getExitReason(i=!1){}isLastEventRecent(i=1e4,t=this.lastEvent){return!!t&&performance.now()-t.timeStamp=0&&this.offsetX>=0&&this.offsetX<150?void 0:""}}onMouseEnter(){this.isMouseOut=!1,this.offsetX=-1,this.offsetY=-1}onMouseLeave(t){this.isMouseOut=!0,this.offsetX=t.clientX,this.offsetY=t.clientY}}w=new WeakMap,P=new WeakMap;class j extends s{constructor(t,e){super(t);l(this,E,null);l(this,L,this.onPopState.bind(this));l(this,T,this.onPushState.bind(this));a(this,"lastPopStateEvent",null);const h=g(this,E,history.pushState);history.pushState=(O,q,G)=>{o(this,T).call(this),h==null||h.apply(history,[O,q,G])},addEventListener("popstate",o(this,L))}destroy(){o(this,E)&&(history.pushState=o(this,E)),removeEventListener("popstate",o(this,L))}onPopState(t){this.lastPopStateEvent=t,this.tracker.trackPageview()}onPushState(){this.tracker.trackPageview()}}E=new WeakMap,L=new WeakMap,T=new WeakMap;class F extends s{constructor(t,e){super(t);l(this,S,this.onVisibilityChange.bind(this));l(this,N);l(this,p,null);a(this,"visibilityState",document.visibilityState);g(this,N,e.hiddenTimeout||4e3),addEventListener("visibilitychange",o(this,S))}destroy(){removeEventListener("visibilitychange",o(this,S))}getExitReason(t=!1){if(t&&this.visibilityState==="hidden"&&this.lastEvent&&performance.now()-this.lastEvent.timeStamp>=1e3)return""}onTimeout(){document.visibilityState==="hidden"&&this.tracker.trackPageview({},!0)}onVisibilityChange(t){this.lastEvent=t,this.visibilityState=document.visibilityState,this.tracker.isMobile&&(o(this,p)&&clearTimeout(o(this,p)),document.visibilityState==="hidden"&&g(this,p,setTimeout(()=>{this.onTimeout()},o(this,N))))}}S=new WeakMap,N=new WeakMap,p=new WeakMap;const c=class c{constructor(i){l(this,C);l(this,v,this.onPageHide.bind(this));a(this,"isMobile",/Mobi|Android|iPhone|iPad|iPod|Opera Mini/i.test(navigator.userAgent||""));a(this,"events",[]);a(this,"extensions",{});a(this,"globalName",null);a(this,"lastPageLoadAt",performance.now());a(this,"lastPageview",null);a(this,"returningVisitor",null);a(this,"trackedEvents",0);a(this,"trackedPageviews",0);if(this.options=i,!i.projectId)throw new Error("Parameter projectId required.");V(this,C,X).call(this),this.isDNTEnabled&&this.options.respectDnt===!0?this.log("DoNotTrack enabled."):(this.loadExtensions(),addEventListener("pagehide",o(this,v)),addEventListener("beforeunload",o(this,v)))}get apiUrl(){return this.options.apiUrl||c.DEFAULT_API_URL}get isDNTEnabled(){return"doNotTrack"in navigator&&navigator.doNotTrack==="1"||"globalPrivacyControl"in navigator&&navigator.globalPrivacyControl===!0}destroy(){this.flushEvents();for(const i in this.extensions)this.extensions[i].destroy();this.extensions={},removeEventListener("pagehide",o(this,v)),removeEventListener("beforeunload",o(this,v)),this.globalName&&delete globalThis[this.globalName]}flushEvents(){const i=this.events.splice(0);i.length&&this.sendBeacon(i)}getBeaconPayload(i){return{events:i,projectId:this.options.projectId,time:Date.now(),uniqueId:this.options.uniqueId}}getExitReason(i=!1){for(const t in this.extensions){const e=this.extensions[t].getExitReason(i);if(e!==void 0)return this.log("exit reason:",{ext:t,result:e}),e}}getExtension(i){return this.extensions[i]}getHostname(){return location.hostname}getOrigin(){return location.origin}getView(){return this.sanitizeUrl(location.href)}getPageviewOptions(i=!1){const t=this.getExitReason(i),e=this.getReferrer(),h=this.getOrigin();return{appVersion:this.options.appVersion,exit:t!==void 0,outbound:t,duration:Math.max(0,Math.floor(performance.now()-this.lastPageLoadAt)),referrer:e&&new URL(e,h).origin===h?"":e,returning:this.returningVisitor===null?void 0:this.returningVisitor,view:this.getView()}}getReferrer(){return document.referrer}hasExtension(i){return!!this.getExtension(i)}loadExtensions(){var i;for(const t in c.EXTENSIONS){let e=((i=this.options)==null?void 0:i[t])!==void 0?this.options[t]:{};typeof e=="boolean"?e={disable:!e}:e.disable=e.disable===void 0?!c.DEFAULT_EXTENSIONS.includes(t):e.disable,e.disable!==!0&&(this.extensions[t]=new c.EXTENSIONS[t](this,e))}}log(...i){this.options.debug&&console.log("[ALTCHA Tracker]",...i)}sanitizeUrl(i){var t;if(i=new URL(i),(t=this.options.allowSearchParams)!=null&&t.length&&i.hostname===this.getHostname())for(const[e]of i.searchParams)this.options.allowSearchParams.includes(e)||i.searchParams.delete(e);else i.search="";return this.hasExtension("hash")||(i.hash=""),i.toString()}sendBeacon(i){if("sendBeacon"in navigator)return navigator.sendBeacon(this.apiUrl,JSON.stringify(this.getBeaconPayload(i)))}trackEvent(i={},t=!1){const e={timestamp:Date.now(),...i};return this.events.push(e),this.trackedEvents+=1,this.log("trackEvent",e),t&&this.flushEvents(),!0}trackPageview(i={},t=!1){const e=this.getPageviewOptions(t);return this.events.length&&e.duration<100&&this.events[this.events.length-1].view===e.view?(this.log("duplicate pageview",e),!1):(this.log("trackPageview",e),this.trackEvent({...e,...i},t),this.trackedPageviews+=1,this.lastPageLoadAt=performance.now(),this.lastPageview=e.view,e.exit&&(this.trackedPageviews=0),!0)}onPageHide(){this.lastPageview!==this.getView()&&this.trackPageview({},!0)}};v=new WeakMap,C=new WeakSet,X=function(){if(this.globalName=this.options.globalName===void 0?c.DEAFAUL_GLOBAL_NAME:this.options.globalName||null,this.globalName){if(globalThis[this.globalName])throw new Error("Another instance of the Tracker is already present in globalThis. Set globalName:null to disable global reference.");globalThis[this.globalName]=this}},a(c,"EXTENSIONS",{click:r,cookie:A,hash:H,keyboard:B,mouse:K,pushstate:j,visibility:F}),a(c,"DEFAULT_API_URL","https://eu.altcha.org/api/v1/event"),a(c,"DEFAULT_EXTENSIONS",["click","keyboard","mouse","pushstate","visibility"]),a(c,"DEAFAUL_GLOBAL_NAME","altchaTracker");let M=c;const u=document.currentScript,U=(_=(D=u==null?void 0:u.getAttribute("src"))==null?void 0:D.match(/([^\.]+)\.altcha\.org/))==null?void 0:_[1],z=((u==null?void 0:u.getAttributeNames())||[]).reduce((d,i)=>{if(i.startsWith("data-")){const t=i.slice(5).replace(/\-([a-z])/g,(h,O)=>O.toUpperCase());let e=u==null?void 0:u.getAttribute(i);e&&(e==="true"?e=!0:e==="false"&&(e=!1),d[t]=e)}return d},{}),Y=new M({apiUrl:U?`https://${U}.altcha.org/api/v1/event`:void 0,...z});n.tracker=Y,Object.defineProperty(n,Symbol.toStringTag,{value:"Module"})});
diff --git a/package-lock.json b/package-lock.json
index 2152568..46142ab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,9 @@
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
+ "husky": "^9.1.6",
"jsdom": "^25.0.1",
+ "prettier": "^3.3.3",
"rimraf": "^6.0.1",
"typescript": "^5.5.3",
"vite": "^5.4.1",
@@ -1125,6 +1127,21 @@
"node": ">= 14"
}
},
+ "node_modules/husky": {
+ "version": "9.1.6",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz",
+ "integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==",
+ "dev": true,
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -1407,6 +1424,21 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prettier": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
+ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
+ "dev": true,
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
diff --git a/package.json b/package.json
index cbe8810..d433507 100644
--- a/package.json
+++ b/package.json
@@ -34,13 +34,18 @@
},
"scripts": {
"dev": "vite",
- "build": "rimraf dist && tsc && vite build -c vite.config.js",
+ "build": "npm run build:module && npm run build:bundle",
+ "build:module": "rimraf dist && tsc && vite build -c vite.config.js",
"build:bundle": "rimraf dist_bundle && tsc && vite build -c vite.bundle.config.js",
+ "format": "prettier --write ./src/**/*",
"preview": "vite preview",
- "test": "vitest run"
+ "test": "vitest run",
+ "prepare": "husky"
},
"devDependencies": {
+ "husky": "^9.1.6",
"jsdom": "^25.0.1",
+ "prettier": "^3.3.3",
"rimraf": "^6.0.1",
"typescript": "^5.5.3",
"vite": "^5.4.1",
diff --git a/src/extensions/click.ext.ts b/src/extensions/click.ext.ts
index 63068c9..bcb5d11 100644
--- a/src/extensions/click.ext.ts
+++ b/src/extensions/click.ext.ts
@@ -8,7 +8,7 @@ export class ClickExtension extends BaseExtension {
constructor(tracker: Tracker, _options: IBaseExtensionOptions) {
super(tracker);
addEventListener('click', this.#_onClick, {
- capture: true,
+ capture: true
});
}
diff --git a/src/extensions/keyboard.ext.ts b/src/extensions/keyboard.ext.ts
index 47433df..8f0d5f4 100644
--- a/src/extensions/keyboard.ext.ts
+++ b/src/extensions/keyboard.ext.ts
@@ -15,10 +15,7 @@ export class KeyboardExtension extends BaseExtension {
}
isLastKeyboardEventCtrl() {
- return (
- !!this.lastEvent &&
- (this.lastEvent.ctrlKey || this.lastEvent.metaKey)
- );
+ return !!this.lastEvent && (this.lastEvent.ctrlKey || this.lastEvent.metaKey);
}
getExitReason(unload: boolean = false) {
diff --git a/src/extensions/pushstate.ext.ts b/src/extensions/pushstate.ext.ts
index d2c3949..7b85a12 100644
--- a/src/extensions/pushstate.ext.ts
+++ b/src/extensions/pushstate.ext.ts
@@ -27,7 +27,7 @@ export class PushstateExtension extends BaseExtension {
}
removeEventListener('popstate', this.#_onPopState);
}
-
+
onPopState(ev: PopStateEvent) {
this.lastPopStateEvent = ev;
this.tracker.trackPageview();
diff --git a/src/extensions/visibility.ext.ts b/src/extensions/visibility.ext.ts
index 87463b2..b4e63cc 100644
--- a/src/extensions/visibility.ext.ts
+++ b/src/extensions/visibility.ext.ts
@@ -5,15 +5,15 @@ import type { Tracker } from '../tracker';
export class VisibilityExtension extends BaseExtension {
readonly #_onVisibilityChange = this.onVisibilityChange.bind(this);
- readonly #hiddenTimeout: number;
+ readonly #hiddenTimeout: number;
- #timeout: ReturnType | null = null;
+ #timeout: ReturnType | null = null;
visibilityState: DocumentVisibilityState = document.visibilityState;
constructor(tracker: Tracker, options: IVivibilityExtensionOptions) {
super(tracker);
- this.#hiddenTimeout = options.hiddenTimeout || 4000;
+ this.#hiddenTimeout = options.hiddenTimeout || 4000;
addEventListener('visibilitychange', this.#_onVisibilityChange);
}
@@ -36,26 +36,26 @@ export class VisibilityExtension extends BaseExtension {
return undefined;
}
- onTimeout() {
- if (document.visibilityState === 'hidden') {
- // track pageview as exit when hidden on timeout
- this.tracker.trackPageview({}, true);
- }
- }
+ onTimeout() {
+ if (document.visibilityState === 'hidden') {
+ // track pageview as exit when hidden on timeout
+ this.tracker.trackPageview({}, true);
+ }
+ }
onVisibilityChange(ev: Event) {
this.lastEvent = ev;
this.visibilityState = document.visibilityState;
- if (this.tracker.isMobile) {
- // set timeout only on mobile devices
- if (this.#timeout) {
- clearTimeout(this.#timeout);
- }
- if (document.visibilityState === 'hidden') {
- this.#timeout = setTimeout(() => {
- this.onTimeout();
- }, this.#hiddenTimeout);
- }
- }
+ if (this.tracker.isMobile) {
+ // set timeout only on mobile devices
+ if (this.#timeout) {
+ clearTimeout(this.#timeout);
+ }
+ if (document.visibilityState === 'hidden') {
+ this.#timeout = setTimeout(() => {
+ this.onTimeout();
+ }, this.#hiddenTimeout);
+ }
+ }
}
}
diff --git a/src/tests/extensions/click.ext.test.ts b/src/tests/extensions/click.ext.test.ts
index 4ae158f..d8e40a4 100644
--- a/src/tests/extensions/click.ext.test.ts
+++ b/src/tests/extensions/click.ext.test.ts
@@ -17,7 +17,7 @@ describe('ClickExtension', () => {
const result = new ClickExtension(tracker, {});
expect(result).toBeInstanceOf(ClickExtension);
expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), {
- capture: true,
+ capture: true
});
});
@@ -40,30 +40,30 @@ describe('ClickExtension', () => {
});
});
- describe('.getExitReason()', () => {
- let a: HTMLAnchorElement;
+ describe('.getExitReason()', () => {
+ let a: HTMLAnchorElement;
- beforeEach(() => {
- a = document.createElement('a');
- });
+ beforeEach(() => {
+ a = document.createElement('a');
+ });
- it('should return outbound url if clicked on a A element with external URL', () => {
- a.setAttribute('href', 'https://google.com/');
- ext.onClick({
- target: a,
- timeStamp: performance.now(),
- } as any);
- expect(ext.getExitReason()).toEqual('https://google.com/');
- });
+ it('should return outbound url if clicked on a A element with external URL', () => {
+ a.setAttribute('href', 'https://google.com/');
+ ext.onClick({
+ target: a,
+ timeStamp: performance.now()
+ } as any);
+ expect(ext.getExitReason()).toEqual('https://google.com/');
+ });
- it('should return undefined if clicked on a A element with internal URL', () => {
- a.setAttribute('href', '/test');
- ext.onClick({
- target: a,
- timeStamp: performance.now(),
- } as any);
- expect(ext.getExitReason()).toBeUndefined();
- });
- });
+ it('should return undefined if clicked on a A element with internal URL', () => {
+ a.setAttribute('href', '/test');
+ ext.onClick({
+ target: a,
+ timeStamp: performance.now()
+ } as any);
+ expect(ext.getExitReason()).toBeUndefined();
+ });
+ });
});
});
diff --git a/src/tests/extensions/cookie.ext.test.ts b/src/tests/extensions/cookie.ext.test.ts
index 973aac3..3f4da76 100644
--- a/src/tests/extensions/cookie.ext.test.ts
+++ b/src/tests/extensions/cookie.ext.test.ts
@@ -2,22 +2,22 @@
* @vitest-environment jsdom
*/
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, vi } from 'vitest';
import { CookieExtension } from '../../extensions/cookie.ext';
import { Tracker } from '../../tracker';
describe('CookieExtenstion', () => {
- const tracker = new Tracker({
- globalName: null,
- projectId: '1',
- });
+ const tracker = new Tracker({
+ globalName: null,
+ projectId: '1'
+ });
- it('should create a new instance and set new cookie', () => {
- const getCookie = vi.spyOn(CookieExtension.prototype, 'getCookie');
- const setCookie = vi.spyOn(CookieExtension.prototype, 'setCookie');
- const result = new CookieExtension(tracker, {});
- expect(result).toBeInstanceOf(CookieExtension);
- expect(getCookie).toHaveBeenCalledOnce();
- expect(setCookie).toHaveBeenCalledOnce();
- })
+ it('should create a new instance and set new cookie', () => {
+ const getCookie = vi.spyOn(CookieExtension.prototype, 'getCookie');
+ const setCookie = vi.spyOn(CookieExtension.prototype, 'setCookie');
+ const result = new CookieExtension(tracker, {});
+ expect(result).toBeInstanceOf(CookieExtension);
+ expect(getCookie).toHaveBeenCalledOnce();
+ expect(setCookie).toHaveBeenCalledOnce();
+ });
});
diff --git a/src/tests/extensions/hash.ext.test.ts b/src/tests/extensions/hash.ext.test.ts
index 21e226e..ba539de 100644
--- a/src/tests/extensions/hash.ext.test.ts
+++ b/src/tests/extensions/hash.ext.test.ts
@@ -39,11 +39,11 @@ describe('HashExtension', () => {
});
describe('.onHashChange()', () => {
- it('should call tracker.trackPageview()', () => {
- const trackedPageviewSpy = vi.spyOn(tracker, 'trackPageview');
- ext.onHashChange();
- expect(trackedPageviewSpy).toHaveBeenCalled();
- });
- });
+ it('should call tracker.trackPageview()', () => {
+ const trackedPageviewSpy = vi.spyOn(tracker, 'trackPageview');
+ ext.onHashChange();
+ expect(trackedPageviewSpy).toHaveBeenCalled();
+ });
+ });
});
});
diff --git a/src/tests/extensions/keyboard.ext.test.ts b/src/tests/extensions/keyboard.ext.test.ts
index 668d3f3..29e9a23 100644
--- a/src/tests/extensions/keyboard.ext.test.ts
+++ b/src/tests/extensions/keyboard.ext.test.ts
@@ -2,102 +2,93 @@
* @vitest-environment jsdom
*/
-import { afterAll, describe, expect, it, vi } from "vitest";
-import { KeyboardExtension } from "../../extensions/keyboard.ext";
-import { Tracker } from "../../tracker";
+import { afterAll, describe, expect, it, vi } from 'vitest';
+import { KeyboardExtension } from '../../extensions/keyboard.ext';
+import { Tracker } from '../../tracker';
-describe("KeyboardExtenstion", () => {
- const tracker = new Tracker({
- globalName: null,
- projectId: "1",
- });
+describe('KeyboardExtenstion', () => {
+ const tracker = new Tracker({
+ globalName: null,
+ projectId: '1'
+ });
- it("should create a new instance and attach keydown listener", () => {
- const addEventListenerSpy = vi.spyOn(globalThis, "addEventListener");
- const result = new KeyboardExtension(tracker, {});
- expect(result).toBeInstanceOf(KeyboardExtension);
- expect(addEventListenerSpy).toHaveBeenCalledWith(
- "keydown",
- expect.any(Function)
- );
- });
+ it('should create a new instance and attach keydown listener', () => {
+ const addEventListenerSpy = vi.spyOn(globalThis, 'addEventListener');
+ const result = new KeyboardExtension(tracker, {});
+ expect(result).toBeInstanceOf(KeyboardExtension);
+ expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
+ });
- describe("methods", () => {
- const ext = new KeyboardExtension(tracker, {});
+ describe('methods', () => {
+ const ext = new KeyboardExtension(tracker, {});
- afterAll(() => {
- if (ext) {
- try {
- ext.destroy();
- } catch {}
- }
- });
+ afterAll(() => {
+ if (ext) {
+ try {
+ ext.destroy();
+ } catch {}
+ }
+ });
- describe(".destroy()", () => {
- it("should remove keydown", () => {
- const removeEventListenerSpy = vi.spyOn(
- globalThis,
- "removeEventListener"
- );
- ext.destroy();
- expect(removeEventListenerSpy).toHaveBeenCalledWith(
- "keydown",
- expect.any(Function)
- );
- });
- });
+ describe('.destroy()', () => {
+ it('should remove keydown', () => {
+ const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener');
+ ext.destroy();
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
+ });
+ });
- describe(".isLastKeyboardEventCtrl()", () => {
- it("should return true if CTRL", () => {
- ext.onKeyDown({
- ctrlKey: true,
- timeStamp: performance.now(),
- } as KeyboardEvent);
- expect(ext.isLastKeyboardEventCtrl()).toBe(true);
- });
+ describe('.isLastKeyboardEventCtrl()', () => {
+ it('should return true if CTRL', () => {
+ ext.onKeyDown({
+ ctrlKey: true,
+ timeStamp: performance.now()
+ } as KeyboardEvent);
+ expect(ext.isLastKeyboardEventCtrl()).toBe(true);
+ });
- it("should return true if META", () => {
- ext.onKeyDown({
- metaKey: true,
- timeStamp: performance.now(),
- } as KeyboardEvent);
- expect(ext.isLastKeyboardEventCtrl()).toBe(true);
- });
- });
+ it('should return true if META', () => {
+ ext.onKeyDown({
+ metaKey: true,
+ timeStamp: performance.now()
+ } as KeyboardEvent);
+ expect(ext.isLastKeyboardEventCtrl()).toBe(true);
+ });
+ });
- describe(".getExitReason()", () => {
- it("should return undefined if CTRL key was pressed but without unload", () => {
- ext.onKeyDown({
- ctrlKey: true,
- timeStamp: performance.now(),
- } as KeyboardEvent);
- expect(ext.getExitReason(false)).toBe(undefined);
- });
+ describe('.getExitReason()', () => {
+ it('should return undefined if CTRL key was pressed but without unload', () => {
+ ext.onKeyDown({
+ ctrlKey: true,
+ timeStamp: performance.now()
+ } as KeyboardEvent);
+ expect(ext.getExitReason(false)).toBe(undefined);
+ });
- it("should return undefined if CTRL key was pressed but a second ago", async () => {
- ext.onKeyDown({
- ctrlKey: true,
- timeStamp: performance.now(),
- } as KeyboardEvent);
- await new Promise((r) => setTimeout(r, 1000));
- expect(ext.getExitReason(true)).toBe(undefined);
- });
+ it('should return undefined if CTRL key was pressed but a second ago', async () => {
+ ext.onKeyDown({
+ ctrlKey: true,
+ timeStamp: performance.now()
+ } as KeyboardEvent);
+ await new Promise((r) => setTimeout(r, 1000));
+ expect(ext.getExitReason(true)).toBe(undefined);
+ });
- it("should return empty string if CTRL key was pressed before unload", () => {
- ext.onKeyDown({
- ctrlKey: true,
- timeStamp: performance.now(),
- } as KeyboardEvent);
- expect(ext.getExitReason(true)).toBe("");
- });
+ it('should return empty string if CTRL key was pressed before unload', () => {
+ ext.onKeyDown({
+ ctrlKey: true,
+ timeStamp: performance.now()
+ } as KeyboardEvent);
+ expect(ext.getExitReason(true)).toBe('');
+ });
- it("should return empty string if META key was pressed before unload", () => {
- ext.onKeyDown({
- metaKey: true,
- timeStamp: performance.now(),
- } as KeyboardEvent);
- expect(ext.getExitReason(true)).toBe("");
- });
- });
- });
+ it('should return empty string if META key was pressed before unload', () => {
+ ext.onKeyDown({
+ metaKey: true,
+ timeStamp: performance.now()
+ } as KeyboardEvent);
+ expect(ext.getExitReason(true)).toBe('');
+ });
+ });
+ });
});
diff --git a/src/tests/extensions/mouse.ext.test.ts b/src/tests/extensions/mouse.ext.test.ts
index e5b45d5..2a769ca 100644
--- a/src/tests/extensions/mouse.ext.test.ts
+++ b/src/tests/extensions/mouse.ext.test.ts
@@ -2,77 +2,68 @@
* @vitest-environment jsdom
*/
-import { afterAll, describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest';
import { MouseExtension } from '../../extensions/mouse.ext';
import { Tracker } from '../../tracker';
describe('MouseExtenstion', () => {
- const tracker = new Tracker({
- globalName: null,
- projectId: '1',
- });
+ const tracker = new Tracker({
+ globalName: null,
+ projectId: '1'
+ });
- it('should create a new instance and attach mouseleave listener', () => {
- const addEventListenerSpy = vi.spyOn(document.body, "addEventListener");
- const result = new MouseExtension(tracker, {});
- expect(result).toBeInstanceOf(MouseExtension);
- expect(addEventListenerSpy).toHaveBeenCalledWith(
- "mouseleave",
- expect.any(Function)
- );
- });
+ it('should create a new instance and attach mouseleave listener', () => {
+ const addEventListenerSpy = vi.spyOn(document.body, 'addEventListener');
+ const result = new MouseExtension(tracker, {});
+ expect(result).toBeInstanceOf(MouseExtension);
+ expect(addEventListenerSpy).toHaveBeenCalledWith('mouseleave', expect.any(Function));
+ });
- describe("methods", () => {
- const ext = new MouseExtension(tracker, {});
+ describe('methods', () => {
+ const ext = new MouseExtension(tracker, {});
- afterAll(() => {
- if (ext) {
- try {
- ext.destroy();
- } catch {}
- }
- });
+ afterAll(() => {
+ if (ext) {
+ try {
+ ext.destroy();
+ } catch {}
+ }
+ });
- describe(".destroy()", () => {
- it("should remove mouseleave", () => {
- const removeEventListenerSpy = vi.spyOn(
- document.body,
- "removeEventListener"
- );
- ext.destroy();
- expect(removeEventListenerSpy).toHaveBeenCalledWith(
- "mouseleave",
- expect.any(Function)
- );
- });
- });
+ describe('.destroy()', () => {
+ it('should remove mouseleave', () => {
+ const removeEventListenerSpy = vi.spyOn(document.body, 'removeEventListener');
+ ext.destroy();
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('mouseleave', expect.any(Function));
+ });
+ });
- describe('.getExitReason()', () => {
- it('should return empty string if cursor is outside the viewport', () => {
- ext.onMouseLeave({
- clientX: 500,
- clientY: -1,
- } as MouseEvent);
- expect(ext.getExitReason()).toBe('');
- });
+ describe('.getExitReason()', () => {
+ it('should return empty string if cursor is outside the viewport', () => {
+ ext.onMouseLeave({
+ clientX: 500,
+ clientY: -1
+ } as MouseEvent);
+ expect(ext.getExitReason()).toBe('');
+ });
- it('should return undefined if cursor is outside the viewport but in the top left corner (navigation buttons)', () => {
- ext.onMouseLeave({
- clientX: 100,
- clientY: -1,
- } as MouseEvent);
- ext.onMouseEnter();
- expect(ext.getExitReason()).toBe(undefined);
- });
+ it('should return undefined if cursor is outside the viewport but in the top left corner (navigation buttons)', () => {
+ ext.onMouseLeave({
+ clientX: 100,
+ clientY: -1
+ } as MouseEvent);
+ ext.onMouseEnter();
+ expect(ext.getExitReason()).toBe(undefined);
+ });
- it('should return undefined if cursor is inside the viewport', () => {
- ext.onMouseLeave({
- clientX: 500,
- clientY: -1,
- } as MouseEvent);
- ext.onMouseEnter();
- expect(ext.getExitReason()).toBe(undefined);
- });
- });
- });
+ it('should return undefined if cursor is inside the viewport', () => {
+ ext.onMouseLeave({
+ clientX: 500,
+ clientY: -1
+ } as MouseEvent);
+ ext.onMouseEnter();
+ expect(ext.getExitReason()).toBe(undefined);
+ });
+ });
+ });
});
diff --git a/src/tests/extensions/pushstate.ext.test.ts b/src/tests/extensions/pushstate.ext.test.ts
index f4beb17..36f667b 100644
--- a/src/tests/extensions/pushstate.ext.test.ts
+++ b/src/tests/extensions/pushstate.ext.test.ts
@@ -46,11 +46,11 @@ describe('PushstateExtension', () => {
});
describe('.onPushState()', () => {
- it('should call tracker.trackPageview()', () => {
- const trackedPageviewSpy = vi.spyOn(tracker, 'trackPageview');
- ext.onPushState();
- expect(trackedPageviewSpy).toHaveBeenCalled();
- });
- });
+ it('should call tracker.trackPageview()', () => {
+ const trackedPageviewSpy = vi.spyOn(tracker, 'trackPageview');
+ ext.onPushState();
+ expect(trackedPageviewSpy).toHaveBeenCalled();
+ });
+ });
});
});
diff --git a/src/tests/extensions/visibility.ext.test.ts b/src/tests/extensions/visibility.ext.test.ts
index 93c2997..e8c8cda 100644
--- a/src/tests/extensions/visibility.ext.test.ts
+++ b/src/tests/extensions/visibility.ext.test.ts
@@ -34,7 +34,10 @@ describe('VisibilityExtension', () => {
it('should remove visibilitychange', () => {
const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener');
ext.destroy();
- expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'visibilitychange',
+ expect.any(Function)
+ );
});
});
});
diff --git a/src/tests/tracker.test.ts b/src/tests/tracker.test.ts
index 9761be4..0348067 100644
--- a/src/tests/tracker.test.ts
+++ b/src/tests/tracker.test.ts
@@ -7,380 +7,382 @@ import { Tracker } from '../tracker';
import type { IEvent, ITrackerOptions } from '../types';
describe('Tracker', () => {
-
- describe('constructor', () => {
- it('should throw error if projectId is not provided', () => {
- expect(() => new Tracker({
- globalName: null,
- } as ITrackerOptions)).toThrow();
- });
-
- it('should throw error if projectId is an empty string', () => {
- expect(() => new Tracker({
- globalName: null,
- projectId: '',
- })).toThrow();
- });
-
- it('should create a new instance and attach pagehide and beforeunload listeners', () => {
- const addEventListenerSpy = vi.spyOn(globalThis, "addEventListener");
- expect(new Tracker({
- globalName: null,
- projectId: '1'
- })).toBeInstanceOf(Tracker);
- expect(addEventListenerSpy).toHaveBeenCalledWith(
- "pagehide",
- expect.any(Function)
- );
- expect(addEventListenerSpy).toHaveBeenCalledWith(
- "beforeunload",
- expect.any(Function)
- );
- });
-
- it('should register a global reference to the instance', () => {
- const t = new Tracker({
- projectId: '1',
- });
- // @ts-expect-error
- expect(globalThis[Tracker.DEAFAUL_GLOBAL_NAME]).toEqual(t);
- t.destroy();
- });
-
- it('should throw if another global reference is already registered', () => {
- const t = new Tracker({
- projectId: '1',
- });
- expect(() => new Tracker({
- projectId: '1',
- } as ITrackerOptions)).toThrow();
- t.destroy();
- });
-
- it('should register a global reference to the instance with a configured name', () => {
- const t = new Tracker({
- globalName: 'test',
- projectId: '1',
- });
- // @ts-expect-error
- expect(globalThis.test).toEqual(t);
- t.destroy();
- });
-
- it('should load default extensions', () => {
- const loadExtensionsSpy = vi.spyOn(Tracker.prototype, "loadExtensions");
- const tracker = new Tracker({
- globalName: null,
- projectId: '1'
- });
- expect(loadExtensionsSpy).toHaveBeenCalledOnce();
- expect(Tracker.DEFAULT_EXTENSIONS.length).toBeGreaterThan(0);
- expect(Object.keys(tracker.extensions)).toEqual(Tracker.DEFAULT_EXTENSIONS);
- });
-
- it('should disable all extensions', () => {
- const tracker = new Tracker({
- globalName: null,
- click: false,
- keyboard: false,
- mouse: false,
- pushstate: false,
- visibility: false,
- projectId: '1'
- });
- expect(Object.keys(tracker.extensions).length).toEqual(0);
- });
-
- it('should enable only pushstate extension', () => {
- const tracker = new Tracker({
- globalName: null,
- click: false,
- keyboard: false,
- mouse: false,
- pushstate: true,
- visibility: false,
- projectId: '1'
- });
- expect(Object.keys(tracker.extensions)).toEqual(['pushstate']);
- });
- });
-
- describe('properties', () => {
- let tracker: Tracker;
-
- afterEach(() => {
- if (tracker) {
- tracker.destroy();
- }
- });
-
- beforeEach(() => {
- tracker = new Tracker({
- globalName: null,
- projectId: '1',
- });
- vi.restoreAllMocks();
- });
-
- describe('.apiUrl', () => {
- it('should return default API URL', () => {
- expect(tracker.apiUrl).toEqual(Tracker.DEFAULT_API_URL);
- });
-
- it('should return custom API URL when configured', () => {
- const apiUrl = 'http://example.com/event';
- const t = new Tracker({
- apiUrl,
- globalName: null,
- projectId: '1',
- });
- expect(t.apiUrl).toEqual(apiUrl);
- t.destroy();
- });
- });
-
- describe('.isDNTEnabled', () => {
- it('should return true if navigator.doNotTrack is 1', () => {
- // @ts-ignore
- navigator.doNotTrack = '1';
- expect(tracker.isDNTEnabled).toEqual(true);
- // @ts-ignore
- navigator.doNotTrack = '0';
- });
-
- it('should return true if navigator.globalPrivacyControl is true', () => {
- // @ts-ignore
- navigator.globalPrivacyControl = true;
- expect(tracker.isDNTEnabled).toEqual(true);
- // @ts-ignore
- navigator.globalPrivacyControl = false;
- });
- });
- });
-
- describe('methods', () => {
- let tracker: Tracker;
-
- afterEach(() => {
- if (tracker) {
- tracker.destroy();
- }
- });
-
- beforeEach(() => {
- tracker = new Tracker({
- globalName: null,
- projectId: '1',
- });
- vi.restoreAllMocks();
- });
-
- describe('.destroy()', () => {
- it('should destroy the instance and remove pagehide listener', () => {
- const removeEventListenerSpy = vi.spyOn(
- globalThis,
- "removeEventListener"
- );
- tracker.destroy();
- expect(removeEventListenerSpy).toHaveBeenCalledWith(
- "pagehide",
- expect.any(Function)
- );
- });
- });
-
- describe('.getBeaconPayload()', () => {
- it('should return an object with events, time and projectId', () => {
- const events: IEvent[] = [{
- timestamp: Date.now(),
- }];
- expect(tracker.getBeaconPayload(events)).toEqual({
- events,
- projectId: tracker.options.projectId,
- time: expect.any(Number),
- });
- });
- });
-
- describe('.getHostname()', () => {
- it('should return current location hostnane (localhost by default in JSDOM)', () => {
- expect(tracker.getHostname()).toEqual('localhost');
- });
- });
-
- describe('.getPageviewOptions()', () => {
- it('should return options for pageview event', () => {
- expect(tracker.getPageviewOptions()).toEqual({
- appVersion: undefined,
- duration: expect.any(Number),
- exit: false,
- outbound: undefined,
- referrer: expect.any(String),
- returning: undefined,
- view: expect.any(String),
- });
- });
-
- it('should return exit reason from .getExitReason()', () => {
- const getExitReasonSpy = vi.spyOn(tracker, 'getExitReason');
- expect(tracker.getPageviewOptions().exit).toEqual(false);
- expect(getExitReasonSpy).toHaveBeenCalled();
- });
-
- it('should return duration', () => {
- tracker.lastPageLoadAt = performance.now() - 1100;
- expect(tracker.getPageviewOptions().duration).toBeGreaterThan(1000);
- });
-
- it('should return referrer from .getReferrer()', () => {
- const getReferrerSpy = vi.spyOn(tracker, 'getReferrer');
- expect(tracker.getPageviewOptions().referrer).toEqual(tracker.getReferrer());
- expect(getReferrerSpy).toHaveBeenCalled();
- });
-
- it('should return view for .getView()', () => {
- const getViewSpy = vi.spyOn(tracker, 'getView');
- expect(tracker.getPageviewOptions().view).toEqual(tracker.getView());
- expect(getViewSpy).toHaveBeenCalled();
- });
- });
-
- describe('.getReferrer()', () => {
- it('should return the document referrer (empty string by default)', () => {
- expect(tracker.getReferrer()).toEqual('');
- });
- });
-
- describe('.getView()', () => {
- it('should return current location href', () => {
- expect(tracker.getView()).toEqual(location.href);
- });
-
- it('should return call .sanitizeUrl()', () => {
- const sanitizeUrlSpy = vi.spyOn(tracker, 'sanitizeUrl');
- expect(tracker.getView()).toEqual(location.href);
- expect(sanitizeUrlSpy).toHaveBeenCalled();
- });
- });
-
- describe('.hasExtension()', () => {
- it('should return true if extension is loaded', () => {
- expect(tracker.hasExtension('pushstate')).toEqual(true);
- });
- it('should return true if extension is not loaded', () => {
- expect(tracker.hasExtension('nonexistent' as any)).toEqual(false);
- });
- });
-
- describe('.log()', () => {
- it('should not log anything if debug is disabled', () => {
- const logSpy = vi.spyOn(console, 'log');
- tracker.log('test');
- expect(logSpy).not.toHaveBeenCalled();
- });
-
- it('should log if debug is enabled', () => {
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => void 0);
- tracker.options.debug = true;
- tracker.log('test');
- expect(logSpy).toHaveBeenCalledOnce();
- });
- });
-
- describe('.sanitizeUrl()', () => {
- it('should remove search params and hash', () => {
- const url = 'https://localhost:1234/test'
- expect(tracker.sanitizeUrl(url + '?test=123#removethis')).toEqual(url);
- });
-
- it('should remove only non-whitelisted search params', () => {
- const url = 'https://localhost:1234/test'
- const t = new Tracker({
- allowSearchParams: ['test1', 'test2'],
- globalName: null,
- projectId: '1'
- });
- expect(t.sanitizeUrl(url + '?abc=test&test1=1&test2=2&test3=3')).toEqual(url + '?test1=1&test2=2');
- t.destroy();
- });
- });
-
- describe('.trackEvent()', () => {
- it('should record an event', () => {
- expect(tracker.trackedEvents).toEqual(0);
- tracker.trackEvent({
- customEvent: 'test',
- props: {
- customerPlan: 'free',
- },
- });
- expect(tracker.trackedEvents).toEqual(1);
- expect(tracker.events.length).toEqual(1);
- expect(tracker.events[0].timestamp).toBeGreaterThan(0);
- });
- });
-
- describe('.trackPageview()', () => {
- it('should record a pageview event', () => {
- expect(tracker.trackedEvents).toEqual(0);
- tracker.trackPageview();
- expect(tracker.trackedEvents).toEqual(1);
- expect(tracker.events.length).toEqual(1);
- expect(tracker.events[0].timestamp).toBeGreaterThan(0);
- });
- });
-
- describe('.flushEvents()', () => {
- it('should reset the internal events array', () => {
- tracker.trackEvent({
- customEvent: 'test',
- });
- expect(tracker.events.length).toEqual(1);
- tracker.flushEvents();
- expect(tracker.events.length).toEqual(0);
- });
-
- it('should call sendBeacon() with recoded events', () => {
- const sendBeaconSpy = vi.spyOn(tracker, 'sendBeacon');
- tracker.trackEvent({
- customEvent: 'test',
- });
- const events = [...tracker.events];
- tracker.flushEvents();
- expect(sendBeaconSpy).toHaveBeenCalledWith(events);
- });
- });
-
- describe('.sendBeacon()', () => {
- it('should call navigator.sendBeacon() with JSON data', () => {
- let sendBeaconArgs: any[] = [];
- navigator.sendBeacon = (...args: any[]) => {
- sendBeaconArgs = args;
- return true;
- };
- const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon');
- tracker.trackEvent({
- customEvent: 'test',
- });
- const events = [...tracker.events];
- tracker.flushEvents();
- expect(sendBeaconSpy).toHaveBeenCalledWith(tracker.apiUrl, expect.any(String));
- expect(JSON.parse(sendBeaconArgs[1])).toEqual({
- events,
- projectId: tracker.options.projectId,
- time: expect.any(Number),
- })
- });
- });
-
- describe('.onPageHide()', () => {
- it('should track pageview and flush events', () => {
- const trackPageviewSpy = vi.spyOn(tracker, 'trackPageview');
- const flushEventsSpy = vi.spyOn(tracker, 'flushEvents');
- tracker.onPageHide();
- expect(trackPageviewSpy).toHaveBeenCalledWith({}, true);
- expect(flushEventsSpy).toHaveBeenCalled();
- });
- });
- });
-});
\ No newline at end of file
+ describe('constructor', () => {
+ it('should throw error if projectId is not provided', () => {
+ expect(
+ () =>
+ new Tracker({
+ globalName: null
+ } as ITrackerOptions)
+ ).toThrow();
+ });
+
+ it('should throw error if projectId is an empty string', () => {
+ expect(
+ () =>
+ new Tracker({
+ globalName: null,
+ projectId: ''
+ })
+ ).toThrow();
+ });
+
+ it('should create a new instance and attach pagehide and beforeunload listeners', () => {
+ const addEventListenerSpy = vi.spyOn(globalThis, 'addEventListener');
+ expect(
+ new Tracker({
+ globalName: null,
+ projectId: '1'
+ })
+ ).toBeInstanceOf(Tracker);
+ expect(addEventListenerSpy).toHaveBeenCalledWith('pagehide', expect.any(Function));
+ expect(addEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
+ });
+
+ it('should register a global reference to the instance', () => {
+ const t = new Tracker({
+ projectId: '1'
+ });
+ // @ts-expect-error
+ expect(globalThis[Tracker.DEAFAUL_GLOBAL_NAME]).toEqual(t);
+ t.destroy();
+ });
+
+ it('should throw if another global reference is already registered', () => {
+ const t = new Tracker({
+ projectId: '1'
+ });
+ expect(
+ () =>
+ new Tracker({
+ projectId: '1'
+ } as ITrackerOptions)
+ ).toThrow();
+ t.destroy();
+ });
+
+ it('should register a global reference to the instance with a configured name', () => {
+ const t = new Tracker({
+ globalName: 'test',
+ projectId: '1'
+ });
+ // @ts-expect-error
+ expect(globalThis.test).toEqual(t);
+ t.destroy();
+ });
+
+ it('should load default extensions', () => {
+ const loadExtensionsSpy = vi.spyOn(Tracker.prototype, 'loadExtensions');
+ const tracker = new Tracker({
+ globalName: null,
+ projectId: '1'
+ });
+ expect(loadExtensionsSpy).toHaveBeenCalledOnce();
+ expect(Tracker.DEFAULT_EXTENSIONS.length).toBeGreaterThan(0);
+ expect(Object.keys(tracker.extensions)).toEqual(Tracker.DEFAULT_EXTENSIONS);
+ });
+
+ it('should disable all extensions', () => {
+ const tracker = new Tracker({
+ globalName: null,
+ click: false,
+ keyboard: false,
+ mouse: false,
+ pushstate: false,
+ visibility: false,
+ projectId: '1'
+ });
+ expect(Object.keys(tracker.extensions).length).toEqual(0);
+ });
+
+ it('should enable only pushstate extension', () => {
+ const tracker = new Tracker({
+ globalName: null,
+ click: false,
+ keyboard: false,
+ mouse: false,
+ pushstate: true,
+ visibility: false,
+ projectId: '1'
+ });
+ expect(Object.keys(tracker.extensions)).toEqual(['pushstate']);
+ });
+ });
+
+ describe('properties', () => {
+ let tracker: Tracker;
+
+ afterEach(() => {
+ if (tracker) {
+ tracker.destroy();
+ }
+ });
+
+ beforeEach(() => {
+ tracker = new Tracker({
+ globalName: null,
+ projectId: '1'
+ });
+ vi.restoreAllMocks();
+ });
+
+ describe('.apiUrl', () => {
+ it('should return default API URL', () => {
+ expect(tracker.apiUrl).toEqual(Tracker.DEFAULT_API_URL);
+ });
+
+ it('should return custom API URL when configured', () => {
+ const apiUrl = 'http://example.com/event';
+ const t = new Tracker({
+ apiUrl,
+ globalName: null,
+ projectId: '1'
+ });
+ expect(t.apiUrl).toEqual(apiUrl);
+ t.destroy();
+ });
+ });
+
+ describe('.isDNTEnabled', () => {
+ it('should return true if navigator.doNotTrack is 1', () => {
+ // @ts-ignore
+ navigator.doNotTrack = '1';
+ expect(tracker.isDNTEnabled).toEqual(true);
+ // @ts-ignore
+ navigator.doNotTrack = '0';
+ });
+
+ it('should return true if navigator.globalPrivacyControl is true', () => {
+ // @ts-ignore
+ navigator.globalPrivacyControl = true;
+ expect(tracker.isDNTEnabled).toEqual(true);
+ // @ts-ignore
+ navigator.globalPrivacyControl = false;
+ });
+ });
+ });
+
+ describe('methods', () => {
+ let tracker: Tracker;
+
+ afterEach(() => {
+ if (tracker) {
+ tracker.destroy();
+ }
+ });
+
+ beforeEach(() => {
+ tracker = new Tracker({
+ globalName: null,
+ projectId: '1'
+ });
+ vi.restoreAllMocks();
+ });
+
+ describe('.destroy()', () => {
+ it('should destroy the instance and remove pagehide listener', () => {
+ const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener');
+ tracker.destroy();
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('pagehide', expect.any(Function));
+ });
+ });
+
+ describe('.getBeaconPayload()', () => {
+ it('should return an object with events, time and projectId', () => {
+ const events: IEvent[] = [
+ {
+ timestamp: Date.now()
+ }
+ ];
+ expect(tracker.getBeaconPayload(events)).toEqual({
+ events,
+ projectId: tracker.options.projectId,
+ time: expect.any(Number)
+ });
+ });
+ });
+
+ describe('.getHostname()', () => {
+ it('should return current location hostnane (localhost by default in JSDOM)', () => {
+ expect(tracker.getHostname()).toEqual('localhost');
+ });
+ });
+
+ describe('.getPageviewOptions()', () => {
+ it('should return options for pageview event', () => {
+ expect(tracker.getPageviewOptions()).toEqual({
+ appVersion: undefined,
+ duration: expect.any(Number),
+ exit: false,
+ outbound: undefined,
+ referrer: expect.any(String),
+ returning: undefined,
+ view: expect.any(String)
+ });
+ });
+
+ it('should return exit reason from .getExitReason()', () => {
+ const getExitReasonSpy = vi.spyOn(tracker, 'getExitReason');
+ expect(tracker.getPageviewOptions().exit).toEqual(false);
+ expect(getExitReasonSpy).toHaveBeenCalled();
+ });
+
+ it('should return duration', () => {
+ tracker.lastPageLoadAt = performance.now() - 1100;
+ expect(tracker.getPageviewOptions().duration).toBeGreaterThan(1000);
+ });
+
+ it('should return referrer from .getReferrer()', () => {
+ const getReferrerSpy = vi.spyOn(tracker, 'getReferrer');
+ expect(tracker.getPageviewOptions().referrer).toEqual(tracker.getReferrer());
+ expect(getReferrerSpy).toHaveBeenCalled();
+ });
+
+ it('should return view for .getView()', () => {
+ const getViewSpy = vi.spyOn(tracker, 'getView');
+ expect(tracker.getPageviewOptions().view).toEqual(tracker.getView());
+ expect(getViewSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('.getReferrer()', () => {
+ it('should return the document referrer (empty string by default)', () => {
+ expect(tracker.getReferrer()).toEqual('');
+ });
+ });
+
+ describe('.getView()', () => {
+ it('should return current location href', () => {
+ expect(tracker.getView()).toEqual(location.href);
+ });
+
+ it('should return call .sanitizeUrl()', () => {
+ const sanitizeUrlSpy = vi.spyOn(tracker, 'sanitizeUrl');
+ expect(tracker.getView()).toEqual(location.href);
+ expect(sanitizeUrlSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('.hasExtension()', () => {
+ it('should return true if extension is loaded', () => {
+ expect(tracker.hasExtension('pushstate')).toEqual(true);
+ });
+ it('should return true if extension is not loaded', () => {
+ expect(tracker.hasExtension('nonexistent' as any)).toEqual(false);
+ });
+ });
+
+ describe('.log()', () => {
+ it('should not log anything if debug is disabled', () => {
+ const logSpy = vi.spyOn(console, 'log');
+ tracker.log('test');
+ expect(logSpy).not.toHaveBeenCalled();
+ });
+
+ it('should log if debug is enabled', () => {
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => void 0);
+ tracker.options.debug = true;
+ tracker.log('test');
+ expect(logSpy).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe('.sanitizeUrl()', () => {
+ it('should remove search params and hash', () => {
+ const url = 'https://localhost:1234/test';
+ expect(tracker.sanitizeUrl(url + '?test=123#removethis')).toEqual(url);
+ });
+
+ it('should remove only non-whitelisted search params', () => {
+ const url = 'https://localhost:1234/test';
+ const t = new Tracker({
+ allowSearchParams: ['test1', 'test2'],
+ globalName: null,
+ projectId: '1'
+ });
+ expect(t.sanitizeUrl(url + '?abc=test&test1=1&test2=2&test3=3')).toEqual(
+ url + '?test1=1&test2=2'
+ );
+ t.destroy();
+ });
+ });
+
+ describe('.trackEvent()', () => {
+ it('should record an event', () => {
+ expect(tracker.trackedEvents).toEqual(0);
+ tracker.trackEvent({
+ customEvent: 'test',
+ props: {
+ customerPlan: 'free'
+ }
+ });
+ expect(tracker.trackedEvents).toEqual(1);
+ expect(tracker.events.length).toEqual(1);
+ expect(tracker.events[0].timestamp).toBeGreaterThan(0);
+ });
+ });
+
+ describe('.trackPageview()', () => {
+ it('should record a pageview event', () => {
+ expect(tracker.trackedEvents).toEqual(0);
+ tracker.trackPageview();
+ expect(tracker.trackedEvents).toEqual(1);
+ expect(tracker.events.length).toEqual(1);
+ expect(tracker.events[0].timestamp).toBeGreaterThan(0);
+ });
+ });
+
+ describe('.flushEvents()', () => {
+ it('should reset the internal events array', () => {
+ tracker.trackEvent({
+ customEvent: 'test'
+ });
+ expect(tracker.events.length).toEqual(1);
+ tracker.flushEvents();
+ expect(tracker.events.length).toEqual(0);
+ });
+
+ it('should call sendBeacon() with recoded events', () => {
+ const sendBeaconSpy = vi.spyOn(tracker, 'sendBeacon');
+ tracker.trackEvent({
+ customEvent: 'test'
+ });
+ const events = [...tracker.events];
+ tracker.flushEvents();
+ expect(sendBeaconSpy).toHaveBeenCalledWith(events);
+ });
+ });
+
+ describe('.sendBeacon()', () => {
+ it('should call navigator.sendBeacon() with JSON data', () => {
+ let sendBeaconArgs: any[] = [];
+ navigator.sendBeacon = (...args: any[]) => {
+ sendBeaconArgs = args;
+ return true;
+ };
+ const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon');
+ tracker.trackEvent({
+ customEvent: 'test'
+ });
+ const events = [...tracker.events];
+ tracker.flushEvents();
+ expect(sendBeaconSpy).toHaveBeenCalledWith(tracker.apiUrl, expect.any(String));
+ expect(JSON.parse(sendBeaconArgs[1])).toEqual({
+ events,
+ projectId: tracker.options.projectId,
+ time: expect.any(Number)
+ });
+ });
+ });
+
+ describe('.onPageHide()', () => {
+ it('should track pageview and flush events', () => {
+ const trackPageviewSpy = vi.spyOn(tracker, 'trackPageview');
+ const flushEventsSpy = vi.spyOn(tracker, 'flushEvents');
+ tracker.onPageHide();
+ expect(trackPageviewSpy).toHaveBeenCalledWith({}, true);
+ expect(flushEventsSpy).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/src/tracker.ts b/src/tracker.ts
index 4c7d59b..2f0ea8e 100644
--- a/src/tracker.ts
+++ b/src/tracker.ts
@@ -1,3 +1,8 @@
+/**
+ * Tracker class for managing event tracking and pageview data collection.
+ * It provides support for tracking various user interactions such as clicks, keyboard actions,
+ * mouse movements, page visibility, pushstate changes, and more through a set of extensions.
+ */
import { BaseExtension } from './extensions/base.ext';
import { ClickExtension } from './extensions/click.ext';
import { CookieExtension } from './extensions/cookie.ext';
@@ -8,9 +13,11 @@ import { PushstateExtension } from './extensions/pushstate.ext';
import { VisibilityExtension } from './extensions/visibility.ext';
import type { IBaseExtensionOptions, IEvent, ITrackerOptions } from './types';
+// Type definition for the name of any extension supported by the tracker
type TExtentionName = keyof typeof Tracker.EXTENSIONS;
export class Tracker {
+ // Static property containing all available extensions
static readonly EXTENSIONS = {
click: ClickExtension,
cookie: CookieExtension,
@@ -18,45 +25,66 @@ export class Tracker {
keyboard: KeyboardExtension,
mouse: MouseExtension,
pushstate: PushstateExtension,
- visibility: VisibilityExtension,
+ visibility: VisibilityExtension
} as const;
+ // Default API endpoint for sending events
static readonly DEFAULT_API_URL: string = 'https://eu.altcha.org/api/v1/event';
+ // Default set of enabled extensions when initializing the tracker
static readonly DEFAULT_EXTENSIONS: TExtentionName[] = [
'click',
'keyboard',
'mouse',
'pushstate',
- 'visibility',
+ 'visibility'
];
+ // Default global name for the tracker instance
static readonly DEAFAUL_GLOBAL_NAME: string = 'altchaTracker';
+ // Bound method to handle the 'pagehide' event
readonly #_onPageHide = this.onPageHide.bind(this);
- readonly isMobile: boolean = /Mobi|Android|iPhone|iPad|iPod|Opera Mini/i.test(navigator.userAgent || '');
+ // Boolean flag indicating if the user is on a mobile device
+ readonly isMobile: boolean = /Mobi|Android|iPhone|iPad|iPod|Opera Mini/i.test(
+ navigator.userAgent || ''
+ );
+ // Array to store tracked events
events: IEvent[] = [];
+ // Object to store initialized extensions
extensions = {} as Record>;
+ // The global name registered for the tracker instance, or null if none is registered
globalName: string | null = null;
+ // Timestamp of the last page load
lastPageLoadAt: number = performance.now();
+ // Last recorded pageview URL
lastPageview: string | null = null;
+ // Boolean flag indicating if the visitor is returning
returningVisitor: boolean | null = null;
+ // Number of events tracked
trackedEvents: number = 0;
+ // Number of pageviews tracked
trackedPageviews: number = 0;
+ /**
+ * Getter for the API URL. Returns the user-provided API URL or the default one.
+ */
get apiUrl() {
return this.options.apiUrl || Tracker.DEFAULT_API_URL;
}
+ /**
+ * Getter to determine if "Do Not Track" (DNT) is enabled in the user's browser.
+ */
get isDNTEnabled() {
return (
('doNotTrack' in navigator && navigator.doNotTrack === '1') ||
@@ -64,23 +92,29 @@ export class Tracker {
);
}
+ /**
+ * Constructor to initialize the Tracker instance.
+ *
+ * @param options - Configuration options for the tracker, including project ID, API URL, and extension settings.
+ */
constructor(readonly options: ITrackerOptions) {
if (!options.projectId) {
throw new Error('Parameter projectId required.');
}
this.#registerGlobalName();
- if (this.isDNTEnabled && this.options.respectDNT === true) {
+ if (this.isDNTEnabled && this.options.respectDnt === true) {
this.log('DoNotTrack enabled.');
} else {
this.loadExtensions();
- // attach both pagehide and beforeunload listeners to handle inconsitent behaviour across browsers
- // safari does not fire beforeunload after clicking on an external link
- // brave does not fire pagehide after clicking on an external link
+ // Attach both pagehide and beforeunload listeners to handle inconsistencies across browsers.
addEventListener('pagehide', this.#_onPageHide);
addEventListener('beforeunload', this.#_onPageHide);
}
}
+ /**
+ * Destroys the tracker instance, removing all listeners and extensions.
+ */
destroy() {
this.flushEvents();
for (const ext in this.extensions) {
@@ -95,6 +129,9 @@ export class Tracker {
}
}
+ /**
+ * Flushes all collected events by sending them to the API.
+ */
flushEvents() {
const events = this.events.splice(0);
if (events.length) {
@@ -102,6 +139,12 @@ export class Tracker {
}
}
+ /**
+ * Builds the payload for sending events to the API.
+ *
+ * @param events - List of events to be sent.
+ * @returns The payload object containing events, project ID, timestamp, and unique ID.
+ */
getBeaconPayload(events: IEvent[]) {
return {
events,
@@ -111,6 +154,12 @@ export class Tracker {
};
}
+ /**
+ * Determines the reason for the user's exit from the page.
+ *
+ * @param unload - Boolean indicating if the reason is triggered by a page unload.
+ * @returns The exit reason, if any, provided by the extensions.
+ */
getExitReason(unload: boolean = false) {
for (const ext in this.extensions) {
const result = this.extensions[ext as TExtentionName].getExitReason(unload);
@@ -122,22 +171,43 @@ export class Tracker {
return undefined;
}
+ /**
+ * Retrieves an extension by name.
+ *
+ * @param name - The name of the extension to retrieve.
+ * @returns The extension instance.
+ */
getExtension(name: TExtentionName) {
return this.extensions[name];
}
+ /**
+ * Returns the current page's hostname.
+ */
getHostname() {
return location.hostname;
}
+ /**
+ * Returns the current page's origin.
+ */
getOrigin() {
return location.origin;
}
+ /**
+ * Returns the sanitized URL of the current page, excluding unwanted query parameters.
+ */
getView() {
return this.sanitizeUrl(location.href);
}
+ /**
+ * Constructs the options for a pageview event.
+ *
+ * @param unload - Boolean indicating if the pageview is triggered by a page unload.
+ * @returns An object containing pageview event details such as duration, referrer, and exit reason.
+ */
getPageviewOptions(unload: boolean = false) {
const exitReason = this.getExitReason(unload);
const referrer = this.getReferrer();
@@ -153,14 +223,26 @@ export class Tracker {
};
}
+ /**
+ * Retrieves the document's referrer URL.
+ */
getReferrer() {
return document.referrer;
}
+ /**
+ * Checks if a particular extension is loaded.
+ *
+ * @param name - The name of the extension.
+ * @returns True if the extension is loaded, false otherwise.
+ */
hasExtension(name: TExtentionName) {
return !!this.getExtension(name);
}
+ /**
+ * Loads all active extensions based on the tracker options and default settings.
+ */
loadExtensions() {
for (const ext in Tracker.EXTENSIONS) {
let extOptions: IBaseExtensionOptions | boolean =
@@ -186,12 +268,23 @@ export class Tracker {
}
}
+ /**
+ * Logs a message to the console if debug mode is enabled.
+ *
+ * @param args - The message or data to log.
+ */
log(...args: any[]) {
if (this.options.debug) {
console.log('[ALTCHA Tracker]', ...args);
}
}
+ /**
+ * Removes query parameters and fragments from the URL based on the tracker settings.
+ *
+ * @param href - The full URL to be sanitized.
+ * @returns The sanitized URL.
+ */
sanitizeUrl(url: string | URL) {
url = new URL(url);
if (this.options.allowSearchParams?.length && url.hostname === this.getHostname()) {
@@ -211,13 +304,29 @@ export class Tracker {
return url.toString();
}
+ /**
+ * Sends the collected events to the server using the Beacon API.
+ *
+ * @param events - The list of events to send.
+ */
sendBeacon(events: IEvent[]) {
if ('sendBeacon' in navigator) {
return navigator.sendBeacon(this.apiUrl, JSON.stringify(this.getBeaconPayload(events)));
}
}
- trackEvent(options: Partial = {}, unload: boolean = false) {
+ /**
+ * Tracks a custom event with optional parameters.
+ *
+ * @param {Partial} options - Optional event details to customize the tracked event. Any properties in the IEvent interface can be passed here. Defaults to an empty object.
+ * @param {boolean} [unload=false] - If set to true, the event is tracked during the page unload (exit). This ensures that the event is reported before the user leaves the page.
+ *
+ * @returns {boolean} - Returns true when the event has been successfully tracked.
+ *
+ * @remarks
+ * The method merges the provided options with the current timestamp to form the event. It pushes the event to the internal event queue and logs the event. If the `unload` flag is true, the events are flushed right away to ensure they are captured before the user navigates away or closes the page.
+ */
+ trackEvent(options: Partial = {}, unload: boolean = false): boolean {
const event = {
timestamp: Date.now(),
...options
@@ -231,7 +340,18 @@ export class Tracker {
return true;
}
- trackPageview(options: Partial = {}, unload: boolean = false) {
+ /**
+ * Tracks a pageview event and handles duplicate pageviews and page load timing.
+ *
+ * @param {Partial} options - Additional pageview details. Any properties in the IEvent interface can be passed here. Defaults to an empty object.
+ * @param {boolean} [unload=false] - If true, the pageview event is tracked during the page unload (exit), ensuring it is reported before the user leaves the page.
+ *
+ * @returns {boolean} - Returns true if the pageview was successfully tracked. Returns false if the pageview is detected as a duplicate.
+ *
+ * @remarks
+ * The method generates pageview-specific options (like view duration) and checks if the pageview is a duplicate (i.e., a pageview of the same page within a very short time frame). If the pageview is not a duplicate, it logs and tracks the event. The `unload` flag ensures that the pageview is reported before the user exits the page or navigates away, by flushing the events queue if necessary. Additionally, the method tracks pageview counts, adjusts state based on whether the user exited the page, and updates the timestamp of the last pageview.
+ */
+ trackPageview(options: Partial = {}, unload: boolean = false): boolean {
const pageviewOptions = this.getPageviewOptions(unload);
if (
this.events.length &&
@@ -259,14 +379,22 @@ export class Tracker {
return true;
}
+ /**
+ * Handles the pagehide event, typically used to send any unsent events before the user leaves the page.
+ */
onPageHide() {
if (this.lastPageview !== this.getView()) {
- // track only if the path is different from the previously recorder one.
- // This is workaround for Safari's Back button from external link which fires pagehide on load
+ // Track only if the path is different from the previously recorder one.
+ // This is a workaround for Safari's Back button from external link which fires pagehide on load
this.trackPageview({}, true);
}
}
+ /**
+ * Registers the global name for the tracker instance.
+ *
+ * @returns True if the global name was registered, false otherwise.
+ */
#registerGlobalName() {
this.globalName =
this.options.globalName === undefined
diff --git a/src/types.ts b/src/types.ts
index ea11996..e2d2ff6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,41 +1,137 @@
+/**
+ * Base interface for extension options, allowing for disabling the extension.
+ */
export interface IBaseExtensionOptions {
+ /**
+ * Flag to disable the extension (default: false).
+ */
disable?: boolean;
}
+/**
+ * Options specific to the Cookie Extension.
+ */
export interface ICookieExtensionOptions extends IBaseExtensionOptions {
+ /**
+ * Number of days before the cookie expires (default: 30 days).
+ */
cookieExpireDays?: number;
+ /**
+ * The name of the cookie (default: '_altcha_visited').
+ */
cookieName?: string;
+ /**
+ * The path where the cookie is available (default: '/').
+ */
cookiePath?: string;
}
+/**
+ * Options specific to the Visibility Extension.
+ */
export interface IVivibilityExtensionOptions extends IBaseExtensionOptions {
+ /**
+ * Timeout (in milliseconds) after which the hidden state is reported as an exit event (default: 4000ms).
+ */
hiddenTimeout?: number;
}
+/**
+ * Event data structure for custom event tracking.
+ */
export interface IEvent {
+ /**
+ * The name of the custom event being tracked.
+ */
customEvent?: string;
+ /**
+ * Duration of the event in milliseconds.
+ */
duration?: number;
+ /**
+ * Flag indicating if this is an exit event (user exiting the app).
+ */
exit?: boolean;
+ /**
+ * Custom properties as a key-value map to add additional context.
+ */
props?: Record;
+ /**
+ * Flag indicating if the user is a returning visitor.
+ */
returning?: boolean;
+ /**
+ * The timestamp when the event is reported, in milliseconds. The origin time of the event is `timestamp - duration`.
+ */
timestamp: number;
+ /**
+ * Name of the view or the URL of the current page.
+ */
view?: string;
}
+/**
+ * Configuration options for initializing the tracker.
+ */
export interface ITrackerOptions {
+ /**
+ * List of URL query parameters that should be tracked. By default, all query parameters are removed.
+ */
allowSearchParams?: string[];
+ /**
+ * Custom API URL to override the default analytics endpoint.
+ */
apiUrl?: string;
+ /**
+ * The current version of the app (e.g., 'v1.0.0').
+ */
appVersion?: string;
+ /**
+ * Click tracking extension configuration (or set to boolean to enable/disable).
+ */
click?: IBaseExtensionOptions | boolean;
+ /**
+ * Cookie tracking extension configuration (or set to boolean to enable/disable).
+ */
cookie?: ICookieExtensionOptions | boolean;
+ /**
+ * Enable or disable debug mode (default: false).
+ */
debug?: boolean;
+ /**
+ * Global variable name for the tracker instance in the browser (set to false or null to disable global name).
+ */
globalName?: string | null | false;
+ /**
+ * Hash tracking extension configuration (or set to boolean to enable/disable).
+ */
hash?: IBaseExtensionOptions | boolean;
+ /**
+ * Keyboard event tracking configuration (or set to boolean to enable/disable).
+ */
keyboard?: IBaseExtensionOptions | boolean;
+ /**
+ * Mouse event tracking configuration (or set to boolean to enable/disable).
+ */
mouse?: IBaseExtensionOptions | boolean;
+ /**
+ * Unique project identifier for the analytics tracker.
+ */
projectId: string;
+ /**
+ * Pushstate event tracking configuration for SPAs (or set to boolean to enable/disable).
+ */
pushstate?: IBaseExtensionOptions | boolean;
- respectDNT?: boolean;
+ /**
+ * Respect the user's 'Do Not Track' browser setting (default: false).
+ */
+ respectDnt?: boolean;
+ /**
+ * Custom unique identifier for the session or user.
+ */
uniqueId?: string;
+ /**
+ * Visibility tracking configuration (or set to boolean to enable/disable).
+ */
visibility?: IVivibilityExtensionOptions | boolean;
-}
\ No newline at end of file
+}