@trlogic/tracker-web
Version:
Formica Tracker Web SDK
3,018 lines • 116 kB
JavaScript
'use strict';
var FingerprintJS = require('@fingerprintjs/fingerprintjs');
var Bowser = require('bowser');
/**
* All variable types the Web SDK can collect.
* Matches tracker-web_current VariableTypeDefinitions + Android structure.
*/
exports.TrackerVariableType = void 0;
(function (TrackerVariableType) {
// --- DOM / Browser ---
TrackerVariableType["URL"] = "URL";
TrackerVariableType["ELEMENT"] = "ELEMENT";
TrackerVariableType["COOKIE"] = "COOKIE";
TrackerVariableType["JAVASCRIPT"] = "JAVASCRIPT";
TrackerVariableType["EVENT"] = "EVENT";
// --- Device ---
TrackerVariableType["IP_ADDRESS"] = "IP_ADDRESS";
TrackerVariableType["DEVICE_FINGERPRINT"] = "DEVICE_FINGERPRINT";
TrackerVariableType["SCREEN_RESOLUTION"] = "SCREEN_RESOLUTION";
TrackerVariableType["USER_AGENT"] = "USER_AGENT";
TrackerVariableType["BROWSER"] = "BROWSER";
TrackerVariableType["OS"] = "OS";
TrackerVariableType["LANGUAGE"] = "LANGUAGE";
TrackerVariableType["TIMEZONE"] = "TIMEZONE";
TrackerVariableType["PLATFORM"] = "PLATFORM";
// --- SDK ---
TrackerVariableType["SDK_VERSION"] = "SDK_VERSION";
TrackerVariableType["TIMESTAMP"] = "TIMESTAMP";
TrackerVariableType["ELAPSED_TIME"] = "ELAPSED_TIME";
TrackerVariableType["CURRENT_TRIGGER_NAME"] = "CURRENT_TRIGGER_NAME";
TrackerVariableType["THEME_MODE"] = "THEME_MODE";
// --- Security / Risk ---
TrackerVariableType["SUSPICIOUS_ACTIVITY"] = "SUSPICIOUS_ACTIVITY";
TrackerVariableType["SUSPICIOUS_BROWSER"] = "SUSPICIOUS_BROWSER";
TrackerVariableType["REMOTE_ACCESS_SCORE"] = "REMOTE_ACCESS_SCORE";
TrackerVariableType["REMOTE_ACCESS_FLAGS"] = "REMOTE_ACCESS_FLAGS";
TrackerVariableType["ENVIRONMENT_CHANGE_FLAGS"] = "ENVIRONMENT_CHANGE_FLAGS";
TrackerVariableType["ENVIRONMENT_CHANGE_SCORE"] = "ENVIRONMENT_CHANGE_SCORE";
TrackerVariableType["NO_MOUSE_MOVE_DURATION_MS"] = "NO_MOUSE_MOVE_DURATION_MS";
TrackerVariableType["CLICK_ONLY_PATTERN"] = "CLICK_ONLY_PATTERN";
TrackerVariableType["FAST_PAGE_TRANSITION"] = "FAST_PAGE_TRANSITION";
TrackerVariableType["FORM_FILL_ANOMALY"] = "FORM_FILL_ANOMALY";
TrackerVariableType["FORM_FILL_DURATION_MS"] = "FORM_FILL_DURATION_MS";
TrackerVariableType["NAVIGATION_VELOCITY"] = "NAVIGATION_VELOCITY";
TrackerVariableType["INPUT_PASTE_RATIO"] = "INPUT_PASTE_RATIO";
TrackerVariableType["HEADLESS_INDICATOR_COUNT"] = "HEADLESS_INDICATOR_COUNT";
TrackerVariableType["REDIRECT_DETECTED"] = "REDIRECT_DETECTED";
// --- Behavioral Biometrics ---
TrackerVariableType["MOUSE_DYNAMICS"] = "MOUSE_DYNAMICS";
TrackerVariableType["KEYSTROKE_DYNAMICS"] = "KEYSTROKE_DYNAMICS";
TrackerVariableType["SCROLL_BEHAVIOR"] = "SCROLL_BEHAVIOR";
TrackerVariableType["SESSION_BEHAVIOR"] = "SESSION_BEHAVIOR";
TrackerVariableType["TOUCH_DYNAMICS"] = "TOUCH_DYNAMICS";
TrackerVariableType["BEHAVIORAL_BIOMETRIC_SCORE"] = "BEHAVIORAL_BIOMETRIC_SCORE";
TrackerVariableType["BEHAVIORAL_BIOMETRIC_FLAGS"] = "BEHAVIORAL_BIOMETRIC_FLAGS";
// --- Activity ---
TrackerVariableType["VIEW_DURATION"] = "VIEW_DURATION";
// --- Custom ---
TrackerVariableType["CUSTOM_TRIGGER_ARGS"] = "CUSTOM_TRIGGER_ARGS";
// --- Network ---
TrackerVariableType["REACHABLE"] = "REACHABLE";
})(exports.TrackerVariableType || (exports.TrackerVariableType = {}));
/**
* Base collector with lazy evaluation and variable mapping.
* Mirrors Android BaseDataCollector.
*
* Collectors only evaluate a variable if it is mapped in the backend config.
*/
class BaseDataCollector {
/**
* Only call the supplier if the variable is actually mapped in at least one config.
* This is the "lazy evaluation" pattern from Android.
*/
collectIfNeeded(data, variableType, supplier, configs) {
if (configs && !BaseDataCollector.isVariableMapped(variableType, configs)) {
return;
}
try {
data.set(variableType, supplier());
}
catch {
// Per-variable error isolation — never crash
}
}
/**
* Maps collected TrackerVariableType → backend field names using config variableMappings.
*/
static mapVariablesToBackend(config, variables) {
const result = {};
const mappings = config.event.variableMappings;
for (const [backendField, variableName] of Object.entries(mappings)) {
const variableType = variableName;
const value = variables.get(variableType);
if (variableType === exports.TrackerVariableType.CUSTOM_TRIGGER_ARGS) {
// Extract specific arg by backendField (mirrors Android customArgs.get(variableName))
if (value && typeof value === "object") {
result[backendField] = value[backendField] ?? null;
}
else {
result[backendField] = null;
}
}
else {
result[backendField] = value ?? null;
}
}
return result;
}
/**
* Check if a variable type is mapped in any config's variableMappings.
*/
static isVariableMapped(variableType, configs) {
return configs.some((config) => {
const mappings = config.event.variableMappings;
return Object.values(mappings).includes(variableType);
});
}
}
/**
* Collector with TTL-based caching.
* Mirrors Android CachedDataCollector.
*
* Each variable can have a different cache strategy (FAST: 10s, MEDIUM: 1min, SLOW: 5min, HEAVY: session).
* If the cached value is still fresh, the supplier is not called.
*/
class CachedDataCollector extends BaseDataCollector {
constructor() {
super(...arguments);
this.cache = new Map();
}
/**
* Collect with caching: only re-evaluate if cache has expired.
*/
collectWithCache(data, variableType, strategy, supplier, configs) {
this.collectIfNeeded(data, variableType, () => {
const cached = this.cache.get(variableType);
const now = Date.now();
if (cached && now - cached.timestamp < cached.ttl) {
return cached.value;
}
const value = supplier();
this.cache.set(variableType, { value, timestamp: now, ttl: strategy });
return value;
}, configs);
}
/**
* Invalidate a specific cached variable.
*/
invalidateCache(variableType) {
this.cache.delete(variableType);
}
/**
* Clear all cached values.
*/
clearCache() {
this.cache.clear();
}
}
/**
* Cache strategy for variable collection.
* Controls how often a variable is re-collected.
*/
exports.CacheStrategy = void 0;
(function (CacheStrategy) {
/** Re-collect every 10 seconds */
CacheStrategy[CacheStrategy["FAST"] = 10000] = "FAST";
/** Re-collect every 1 minute */
CacheStrategy[CacheStrategy["MEDIUM"] = 60000] = "MEDIUM";
/** Re-collect every 5 minutes */
CacheStrategy[CacheStrategy["SLOW"] = 300000] = "SLOW";
/** Collect once per session (never expires while SDK is alive) */
CacheStrategy[CacheStrategy["HEAVY"] = 9007199254740991] = "HEAVY";
})(exports.CacheStrategy || (exports.CacheStrategy = {}));
/**
* Welford's online algorithm for computing running statistics in O(1) memory.
* No arrays needed — maintains mean, variance, min, max incrementally.
*/
class RunningStats {
constructor() {
this.n = 0;
this.m = 0; // running mean
this.m2 = 0; // running sum of squared deviations
this.lo = Infinity;
this.hi = -Infinity;
}
push(value) {
this.n++;
const delta = value - this.m;
this.m += delta / this.n;
const delta2 = value - this.m;
this.m2 += delta * delta2;
if (value < this.lo)
this.lo = value;
if (value > this.hi)
this.hi = value;
}
getMean() {
return this.n === 0 ? 0 : this.m;
}
getStddev() {
return this.n < 2 ? 0 : Math.sqrt(this.m2 / (this.n - 1));
}
getMin() {
return this.n === 0 ? 0 : this.lo;
}
getMax() {
return this.n === 0 ? 0 : this.hi;
}
getCount() {
return this.n;
}
reset() {
this.n = 0;
this.m = 0;
this.m2 = 0;
this.lo = Infinity;
this.hi = -Infinity;
}
toJSON() {
return {
mean: this.getMean(),
stddev: this.getStddev(),
min: this.getMin(),
max: this.getMax(),
count: this.getCount(),
};
}
}
/**
* Lightweight trailing-edge throttle for high-frequency DOM events.
* Drops intermediate calls, only executes at most once per `intervalMs`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function throttle(fn, intervalMs) {
let lastCall = 0;
return ((...args) => {
const now = performance.now();
if (now - lastCall >= intervalMs) {
lastCall = now;
fn(...args);
}
});
}
const MAX_PATH_SEGMENTS = 20;
const PAUSE_THRESHOLD_MS = 200;
const IDLE_THRESHOLD_MS = 5000;
const IDLE_CHECK_INTERVAL_MS = 5000;
const INTERACTION_WINDOW_MS = 60000;
const BURST_WINDOW_MS = 3000;
const BURST_MIN_EVENTS = 5;
const TAP_MAX_DURATION_MS = 300;
const SWIPE_MIN_DISPLACEMENT_PX = 50;
const INTER_KEY_MAX_GAP_MS = 2000;
/**
* Collects behavioral biometric signals from user interactions.
* Computes aggregate statistics from mouse, keyboard, scroll, session, and touch data.
*
* Design principles:
* - All DOM listeners use `{ passive: true }` to avoid blocking the main thread
* - High-frequency events (mousemove, scroll, touchmove) are throttled
* - All statistics use Welford's online algorithm (O(1) memory)
* - No raw event streams stored — only summary aggregates
* - `collect()` returns pre-computed summaries; called by EventCoordinator on existing events
*/
class BehavioralCollector extends CachedDataCollector {
constructor() {
super(...arguments);
// === Mouse tracking state ===
this.mouseVelocityStats = new RunningStats();
this.mouseAccelStats = new RunningStats();
this.lastMouseSample = null;
this.pathSegments = [];
this.currentPathLength = 0;
this.currentPathStartX = 0;
this.currentPathStartY = 0;
this.angularVarianceStats = new RunningStats();
this.lastAngle = null;
this.lastMouseSampleTs = 0;
// === Keystroke tracking state ===
this.lastKeydownTs = 0;
this.lastKeyupTs = 0;
this.interKeyStats = new RunningStats();
this.dwellTimeStats = new RunningStats();
this.flightTimeStats = new RunningStats();
this.pendingKeydowns = new Map();
// === Scroll tracking state ===
this.lastScrollY = 0;
this.lastScrollTs = 0;
this.maxScrollDepthPx = 0;
this.scrollSpeedStats = new RunningStats();
this.scrollDirectionChanges = 0;
this.lastScrollDirection = 0;
this.scrollTotalDistance = 0;
this.smoothScrollCount = 0;
this.totalScrollSamples = 0;
// === Session tracking state ===
this.lastAnyInteractionTs = Date.now();
this.idleCount = 0;
this.totalIdleMs = 0;
this.maxIdleMs = 0;
this.currentIdleStart = null;
this.tabSwitchCount = 0;
this.totalHiddenMs = 0;
this.lastHiddenTs = null;
this.interactionTimestamps = [];
this.burstCount = 0;
// === Touch tracking state ===
this.touchSupported = false;
this.touchRadiusStats = new RunningStats();
this.touchPressureStats = new RunningStats();
this.swipeVelocityStats = new RunningStats();
this.tapCount = 0;
this.swipeCount = 0;
this.activeTouchStart = null;
// === Lifecycle ===
this.listenersAttached = false;
this.idleCheckIntervalId = null;
this.initTs = 0;
// === Bound handlers (for cleanup) ===
this.boundMouseHandler = null;
this.boundKeydownHandler = null;
this.boundKeyupHandler = null;
this.boundScrollHandler = null;
this.boundVisibilityHandler = null;
this.boundTouchStartHandler = null;
this.boundTouchMoveHandler = null;
this.boundTouchEndHandler = null;
}
collect() {
const data = new Map();
const sessionAge = Date.now() - this.initTs;
this.collectWithCache(data, exports.TrackerVariableType.MOUSE_DYNAMICS, exports.CacheStrategy.FAST, () => this.computeMouseDynamics());
this.collectWithCache(data, exports.TrackerVariableType.KEYSTROKE_DYNAMICS, exports.CacheStrategy.FAST, () => this.computeKeystrokeDynamics());
this.collectWithCache(data, exports.TrackerVariableType.SCROLL_BEHAVIOR, exports.CacheStrategy.FAST, () => this.computeScrollBehavior());
this.collectWithCache(data, exports.TrackerVariableType.SESSION_BEHAVIOR, exports.CacheStrategy.FAST, () => this.computeSessionBehavior());
if (this.touchSupported) {
this.collectWithCache(data, exports.TrackerVariableType.TOUCH_DYNAMICS, exports.CacheStrategy.FAST, () => this.computeTouchDynamics());
}
else {
this.collectIfNeeded(data, exports.TrackerVariableType.TOUCH_DYNAMICS, () => null);
}
this.collectIfNeeded(data, exports.TrackerVariableType.BEHAVIORAL_BIOMETRIC_SCORE, () => this.computeBiometricScore(sessionAge));
this.collectIfNeeded(data, exports.TrackerVariableType.BEHAVIORAL_BIOMETRIC_FLAGS, () => this.computeBiometricFlags(sessionAge));
return data;
}
// === Lifecycle ===
init() {
this.initTs = Date.now();
this.lastAnyInteractionTs = Date.now();
this.touchSupported = "ontouchstart" in window;
this.attachListeners();
this.startIdleCheck();
}
destroy() {
this.detachListeners();
this.stopIdleCheck();
}
resetRollingWindow() {
// Prune interaction timestamps to last 60s
const cutoff = Date.now() - INTERACTION_WINDOW_MS;
this.interactionTimestamps = this.interactionTimestamps.filter((t) => t >= cutoff);
}
// === Mouse dynamics computation ===
computeMouseDynamics() {
const vStats = this.mouseVelocityStats;
const aStats = this.mouseAccelStats;
let straightness = 0;
if (this.pathSegments.length > 0) {
let totalRatio = 0;
for (const seg of this.pathSegments) {
totalRatio += seg.pathLength > 0 ? seg.displacement / seg.pathLength : 0;
}
straightness = totalRatio / this.pathSegments.length;
}
const angStats = this.angularVarianceStats;
const jitter = angStats.getCount() > 1 ? Math.min(1, angStats.getStddev() / Math.PI) : 0;
return {
velocity: { mean: vStats.getMean(), max: vStats.getMax(), stddev: vStats.getStddev() },
acceleration: { mean: aStats.getMean(), max: aStats.getMax() },
straightness,
jitter,
sampleCount: vStats.getCount(),
};
}
// === Keystroke dynamics computation ===
computeKeystrokeDynamics() {
const ikStats = this.interKeyStats;
const mean = ikStats.getMean();
const stddev = ikStats.getStddev();
const rhythmConsistency = mean > 0 && ikStats.getCount() > 2 ? Math.max(0, Math.min(1, 1 - stddev / mean)) : 0;
return {
interKeyDelay: { mean, stddev, min: ikStats.getMin(), max: ikStats.getMax() },
dwellTime: { mean: this.dwellTimeStats.getMean(), stddev: this.dwellTimeStats.getStddev() },
flightTime: { mean: this.flightTimeStats.getMean(), stddev: this.flightTimeStats.getStddev() },
rhythmConsistency,
sampleCount: ikStats.getCount(),
};
}
// === Scroll behavior computation ===
computeScrollBehavior() {
const pageHeight = document.documentElement.scrollHeight - window.innerHeight;
const maxDepthPercent = pageHeight > 0 ? Math.min(100, (this.maxScrollDepthPx / pageHeight) * 100) : 0;
return {
maxDepthPercent,
avgSpeed: this.scrollSpeedStats.getMean(),
directionChanges: this.scrollDirectionChanges,
totalDistance: this.scrollTotalDistance,
smoothness: this.totalScrollSamples > 0 ? this.smoothScrollCount / this.totalScrollSamples : 0,
sampleCount: this.totalScrollSamples,
};
}
// === Session behavior computation ===
computeSessionBehavior() {
const now = Date.now();
const cutoff = now - INTERACTION_WINDOW_MS;
const recentInteractions = this.interactionTimestamps.filter((t) => t >= cutoff).length;
return {
idle: { count: this.idleCount, totalMs: this.totalIdleMs, maxMs: this.maxIdleMs },
focus: { tabSwitchCount: this.tabSwitchCount, totalHiddenMs: this.totalHiddenMs },
tempo: { interactionsPerMinute: recentInteractions, burstCount: this.burstCount },
};
}
// === Touch dynamics computation ===
computeTouchDynamics() {
return {
avgTouchRadius: this.touchRadiusStats.getMean(),
avgPressure: this.touchPressureStats.getMean(),
swipeVelocity: { mean: this.swipeVelocityStats.getMean(), max: this.swipeVelocityStats.getMax() },
tapCount: this.tapCount,
swipeCount: this.swipeCount,
sampleCount: this.touchRadiusStats.getCount(),
};
}
// === Composite biometric score ===
computeBiometricScore(sessionAgeMs) {
let score = 0;
const minAge = 5000; // Need at least 5s of data
if (sessionAgeMs < minAge)
return 0;
// Mouse anomaly (weight: 0.25)
const mouseDyn = this.computeMouseDynamics();
let mouseAnomaly = 0;
if (mouseDyn.sampleCount > 10) {
if (mouseDyn.straightness > 0.95)
mouseAnomaly += 0.4;
if (mouseDyn.jitter < 0.05)
mouseAnomaly += 0.3;
if (mouseDyn.velocity.stddev < 0.01 && mouseDyn.velocity.mean > 0)
mouseAnomaly += 0.3;
}
score += Math.min(1, mouseAnomaly) * 0.25;
// Keystroke anomaly (weight: 0.20)
const keyDyn = this.computeKeystrokeDynamics();
let keystrokeAnomaly = 0;
if (keyDyn.sampleCount > 10) {
if (keyDyn.rhythmConsistency > 0.9)
keystrokeAnomaly += 0.5;
if (keyDyn.dwellTime.mean < 10 && keyDyn.dwellTime.mean > 0)
keystrokeAnomaly += 0.5;
}
score += Math.min(1, keystrokeAnomaly) * 0.2;
// Scroll anomaly (weight: 0.15)
const scrollBeh = this.computeScrollBehavior();
let scrollAnomaly = 0;
if (scrollBeh.sampleCount > 3) {
if (scrollBeh.smoothness < 0.2)
scrollAnomaly += 0.5;
if (scrollBeh.directionChanges === 0 && scrollBeh.totalDistance > 500)
scrollAnomaly += 0.5;
}
score += Math.min(1, scrollAnomaly) * 0.15;
// Session anomaly (weight: 0.15)
const sessBeh = this.computeSessionBehavior();
let sessionAnomaly = 0;
if (sessionAgeMs > 60000) {
if (sessBeh.idle.count === 0)
sessionAnomaly += 0.3;
if (sessBeh.focus.tabSwitchCount === 0)
sessionAnomaly += 0.2;
}
if (sessBeh.tempo.interactionsPerMinute > 200)
sessionAnomaly += 0.5;
else if (sessBeh.tempo.interactionsPerMinute < 1 && sessionAgeMs > 30000)
sessionAnomaly += 0.3;
score += Math.min(1, sessionAnomaly) * 0.15;
// Touch anomaly (weight: 0.10)
if (this.touchSupported) {
const touchDyn = this.computeTouchDynamics();
let touchAnomaly = 0;
if (touchDyn.sampleCount > 5) {
if (touchDyn.avgPressure === 0)
touchAnomaly += 0.5;
if (touchDyn.avgTouchRadius === 0)
touchAnomaly += 0.5;
}
score += Math.min(1, touchAnomaly) * 0.1;
}
// Data absence (weight: 0.15)
let absenceAnomaly = 0;
if (sessionAgeMs > 30000) {
if (mouseDyn.sampleCount === 0 && !this.touchSupported)
absenceAnomaly += 0.33;
if (keyDyn.sampleCount === 0)
absenceAnomaly += 0.33;
if (scrollBeh.sampleCount === 0)
absenceAnomaly += 0.34;
}
score += Math.min(1, absenceAnomaly) * 0.15;
return Math.min(1, score);
}
// === Biometric flags ===
computeBiometricFlags(sessionAgeMs) {
const mouseDyn = this.computeMouseDynamics();
const keyDyn = this.computeKeystrokeDynamics();
const scrollBeh = this.computeScrollBehavior();
const sessBeh = this.computeSessionBehavior();
return {
perfectMouseLinearity: mouseDyn.sampleCount > 10 && mouseDyn.straightness > 0.95,
zeroMouseJitter: mouseDyn.sampleCount > 10 && mouseDyn.jitter < 0.05,
uniformKeystrokeTiming: keyDyn.sampleCount > 10 && keyDyn.rhythmConsistency > 0.9,
noKeystrokeData: keyDyn.sampleCount === 0 && sessionAgeMs > 30000,
programmaticScrolling: scrollBeh.sampleCount > 3 && scrollBeh.smoothness < 0.2,
noIdlePeriods: sessBeh.idle.count === 0 && sessionAgeMs > 60000,
noTouchPressure: this.touchSupported && this.touchPressureStats.getCount() > 5 && this.touchPressureStats.getMean() === 0,
abnormalTempo: sessBeh.tempo.interactionsPerMinute > 200 || (sessBeh.tempo.interactionsPerMinute < 1 && sessionAgeMs > 30000),
};
}
// === DOM Listener attachment ===
attachListeners() {
if (this.listenersAttached)
return;
this.listenersAttached = true;
// --- Mouse (throttled 50ms) ---
this.boundMouseHandler = throttle((e) => {
this.onMouseSample(e.clientX, e.clientY, performance.now());
this.recordInteraction();
}, 50);
window.addEventListener("mousemove", this.boundMouseHandler, { passive: true });
// --- Keydown ---
this.boundKeydownHandler = (e) => {
if (e.repeat)
return;
const now = performance.now();
// Inter-key delay
if (this.lastKeydownTs > 0) {
const gap = now - this.lastKeydownTs;
if (gap < INTER_KEY_MAX_GAP_MS) {
this.interKeyStats.push(gap);
}
}
this.lastKeydownTs = now;
// Flight time (keyup → keydown)
if (this.lastKeyupTs > 0) {
const flight = now - this.lastKeyupTs;
if (flight < INTER_KEY_MAX_GAP_MS) {
this.flightTimeStats.push(flight);
}
}
this.pendingKeydowns.set(e.code, now);
this.recordInteraction();
};
document.addEventListener("keydown", this.boundKeydownHandler, { passive: true });
// --- Keyup ---
this.boundKeyupHandler = (e) => {
const now = performance.now();
this.lastKeyupTs = now;
const downTs = this.pendingKeydowns.get(e.code);
if (downTs !== undefined) {
const dwell = now - downTs;
this.dwellTimeStats.push(dwell);
this.pendingKeydowns.delete(e.code);
}
};
document.addEventListener("keyup", this.boundKeyupHandler, { passive: true });
// --- Scroll (throttled 200ms) ---
this.boundScrollHandler = throttle(() => {
this.onScrollSample();
this.recordInteraction();
}, 200);
window.addEventListener("scroll", this.boundScrollHandler, { passive: true });
// --- Visibility change ---
this.boundVisibilityHandler = () => {
const now = Date.now();
if (document.hidden) {
this.lastHiddenTs = now;
}
else {
if (this.lastHiddenTs !== null) {
this.totalHiddenMs += now - this.lastHiddenTs;
this.lastHiddenTs = null;
}
this.tabSwitchCount++;
}
};
document.addEventListener("visibilitychange", this.boundVisibilityHandler);
// --- Touch (conditional) ---
if (this.touchSupported) {
this.boundTouchStartHandler = (e) => {
const touch = e.touches[0];
if (!touch)
return;
this.activeTouchStart = { x: touch.clientX, y: touch.clientY, t: performance.now() };
this.touchRadiusStats.push((touch.radiusX + touch.radiusY) / 2);
if (touch.force > 0)
this.touchPressureStats.push(touch.force);
this.recordInteraction();
};
document.addEventListener("touchstart", this.boundTouchStartHandler, { passive: true });
this.boundTouchMoveHandler = throttle((e) => {
const touch = e.touches[0];
if (!touch)
return;
this.touchRadiusStats.push((touch.radiusX + touch.radiusY) / 2);
if (touch.force > 0)
this.touchPressureStats.push(touch.force);
}, 100);
document.addEventListener("touchmove", this.boundTouchMoveHandler, { passive: true });
this.boundTouchEndHandler = (e) => {
if (!this.activeTouchStart)
return;
const touch = e.changedTouches[0];
if (!touch)
return;
const now = performance.now();
const duration = now - this.activeTouchStart.t;
const dx = touch.clientX - this.activeTouchStart.x;
const dy = touch.clientY - this.activeTouchStart.y;
const displacement = Math.sqrt(dx * dx + dy * dy);
if (duration < TAP_MAX_DURATION_MS && displacement < SWIPE_MIN_DISPLACEMENT_PX) {
this.tapCount++;
}
else if (displacement >= SWIPE_MIN_DISPLACEMENT_PX) {
this.swipeCount++;
const velocity = displacement / duration;
this.swipeVelocityStats.push(velocity);
}
this.activeTouchStart = null;
};
document.addEventListener("touchend", this.boundTouchEndHandler, { passive: true });
}
}
detachListeners() {
if (this.boundMouseHandler)
window.removeEventListener("mousemove", this.boundMouseHandler);
if (this.boundKeydownHandler)
document.removeEventListener("keydown", this.boundKeydownHandler);
if (this.boundKeyupHandler)
document.removeEventListener("keyup", this.boundKeyupHandler);
if (this.boundScrollHandler)
window.removeEventListener("scroll", this.boundScrollHandler);
if (this.boundVisibilityHandler)
document.removeEventListener("visibilitychange", this.boundVisibilityHandler);
if (this.boundTouchStartHandler)
document.removeEventListener("touchstart", this.boundTouchStartHandler);
if (this.boundTouchMoveHandler)
document.removeEventListener("touchmove", this.boundTouchMoveHandler);
if (this.boundTouchEndHandler)
document.removeEventListener("touchend", this.boundTouchEndHandler);
this.listenersAttached = false;
}
// === Mouse sample processing ===
onMouseSample(x, y, t) {
if (this.lastMouseSample) {
const dt = t - this.lastMouseSample.t;
if (dt <= 0)
return;
const dx = x - this.lastMouseSample.x;
const dy = y - this.lastMouseSample.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const velocity = dist / dt;
this.mouseVelocityStats.push(velocity);
// Acceleration
const dv = Math.abs(velocity - this.lastMouseSample.velocity);
const accel = dv / dt;
this.mouseAccelStats.push(accel);
// Path straightness — track segments between pauses
const timeSinceLastSample = t - this.lastMouseSampleTs;
if (timeSinceLastSample > PAUSE_THRESHOLD_MS && this.currentPathLength > 0) {
// End previous segment
const dispX = this.lastMouseSample.x - this.currentPathStartX;
const dispY = this.lastMouseSample.y - this.currentPathStartY;
const displacement = Math.sqrt(dispX * dispX + dispY * dispY);
this.pushPathSegment({ displacement, pathLength: this.currentPathLength });
this.currentPathLength = 0;
this.currentPathStartX = x;
this.currentPathStartY = y;
}
else if (this.currentPathLength === 0) {
this.currentPathStartX = this.lastMouseSample.x;
this.currentPathStartY = this.lastMouseSample.y;
}
this.currentPathLength += dist;
// Angular jitter
if (dist > 1) {
const angle = Math.atan2(dy, dx);
if (this.lastAngle !== null) {
let angleDiff = Math.abs(angle - this.lastAngle);
if (angleDiff > Math.PI)
angleDiff = 2 * Math.PI - angleDiff;
this.angularVarianceStats.push(angleDiff);
}
this.lastAngle = angle;
}
this.lastMouseSample = { x, y, t, velocity };
}
else {
this.lastMouseSample = { x, y, t, velocity: 0 };
this.currentPathStartX = x;
this.currentPathStartY = y;
}
this.lastMouseSampleTs = t;
}
pushPathSegment(segment) {
if (this.pathSegments.length >= MAX_PATH_SEGMENTS) {
this.pathSegments.shift();
}
this.pathSegments.push(segment);
}
// === Scroll sample processing ===
onScrollSample() {
const scrollY = window.scrollY;
const now = performance.now();
if (scrollY > this.maxScrollDepthPx) {
this.maxScrollDepthPx = scrollY;
}
if (this.lastScrollTs > 0) {
const dt = now - this.lastScrollTs;
const deltaY = scrollY - this.lastScrollY;
const absDelta = Math.abs(deltaY);
if (dt > 0 && absDelta > 0) {
const speed = absDelta / dt;
this.scrollSpeedStats.push(speed);
this.scrollTotalDistance += absDelta;
this.totalScrollSamples++;
// Smoothness: small increments vs large jumps
if (absDelta < 100)
this.smoothScrollCount++;
// Direction changes
const dir = deltaY > 0 ? 1 : -1;
if (this.lastScrollDirection !== 0 && dir !== this.lastScrollDirection) {
this.scrollDirectionChanges++;
}
this.lastScrollDirection = dir;
}
}
this.lastScrollY = scrollY;
this.lastScrollTs = now;
}
// === Interaction tracking ===
recordInteraction() {
const now = Date.now();
this.lastAnyInteractionTs = now;
this.interactionTimestamps.push(now);
// Close idle period if active
if (this.currentIdleStart !== null) {
const idleDuration = now - this.currentIdleStart;
this.totalIdleMs += idleDuration;
if (idleDuration > this.maxIdleMs)
this.maxIdleMs = idleDuration;
this.idleCount++;
this.currentIdleStart = null;
}
// Detect bursts
const burstCutoff = now - BURST_WINDOW_MS;
const recentCount = this.interactionTimestamps.filter((t) => t >= burstCutoff).length;
if (recentCount === BURST_MIN_EVENTS) {
this.burstCount++;
}
// Prune old timestamps (keep last 60s)
if (this.interactionTimestamps.length > 500) {
const cutoff = now - INTERACTION_WINDOW_MS;
this.interactionTimestamps = this.interactionTimestamps.filter((t) => t >= cutoff);
}
}
// === Idle check ===
startIdleCheck() {
this.idleCheckIntervalId = setInterval(() => {
const now = Date.now();
if (now - this.lastAnyInteractionTs > IDLE_THRESHOLD_MS && this.currentIdleStart === null) {
this.currentIdleStart = this.lastAnyInteractionTs;
}
}, IDLE_CHECK_INTERVAL_MS);
}
stopIdleCheck() {
if (this.idleCheckIntervalId !== null) {
clearInterval(this.idleCheckIntervalId);
this.idleCheckIntervalId = null;
}
}
}
/** SDK version — auto-referenced by collectors. */
const SDK_VERSION = "2.0.0";
/**
* Browser/OS/platform detection using Bowser.
* Mirrors Android DeviceCollector's device info gathering.
*/
class BrowserDetector {
static getParser() {
if (!BrowserDetector.parser) {
BrowserDetector.parser = Bowser.getParser(navigator.userAgent);
}
return BrowserDetector.parser;
}
static getBrowser() {
const browser = BrowserDetector.getParser().getBrowser();
return {
name: browser.name ?? "Unknown",
version: browser.version ?? "Unknown",
};
}
static getOS() {
const os = BrowserDetector.getParser().getOS();
return {
name: os.name ?? "Unknown",
version: os.version ?? "Unknown",
};
}
static getPlatform() {
const type = BrowserDetector.getParser().getPlatformType();
if (type === "mobile")
return "mobile";
if (type === "tablet")
return "tablet";
return "desktop";
}
static getLanguage() {
return navigator.language || "unknown";
}
static getTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
catch {
return "unknown";
}
}
/**
* Detect if the browser looks suspicious (bot, headless, automated).
* Returns true if known bot user-agent patterns are detected.
*/
static isSuspiciousBrowser() {
const ua = navigator.userAgent.toLowerCase();
const botPatterns = [
"bot",
"crawler",
"spider",
"headless",
"phantom",
"selenium",
"puppeteer",
"playwright",
"webdriver",
"electron",
];
return botPatterns.some((p) => ua.includes(p));
}
/**
* Count headless/bot indicators in the current environment.
*/
static getHeadlessIndicatorCount() {
let count = 0;
// webdriver flag
if (navigator.webdriver)
count++;
// no languages
if (!navigator.languages || navigator.languages.length === 0)
count++;
// no plugins
if (!navigator.plugins || navigator.plugins.length === 0)
count++;
// suspicious user-agent patterns
if (BrowserDetector.isSuspiciousBrowser())
count++;
// WebGL availability check
try {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
if (!gl)
count++;
}
catch {
count++;
}
// screen dimensions anomaly (0x0 or very small)
if (screen.width === 0 || screen.height === 0)
count++;
return count;
}
/** Force parser re-creation (e.g. after UA change in tests). */
static reset() {
BrowserDetector.parser = null;
}
}
BrowserDetector.parser = null;
/**
* Collects device/browser/environment information.
* Mirrors Android DeviceCollector — adapted for web environment.
*/
class DeviceCollector extends CachedDataCollector {
constructor() {
super(...arguments);
this.fingerprintPromise = null;
this.resolvedFingerprint = null;
this.initTimestamp = Date.now();
}
collect() {
const data = new Map();
this.collectWithCache(data, exports.TrackerVariableType.DEVICE_FINGERPRINT, exports.CacheStrategy.HEAVY, () => this.getFingerprint());
this.collectWithCache(data, exports.TrackerVariableType.SCREEN_RESOLUTION, exports.CacheStrategy.HEAVY, () => `${screen.width}x${screen.height}`);
this.collectWithCache(data, exports.TrackerVariableType.USER_AGENT, exports.CacheStrategy.HEAVY, () => navigator.userAgent);
this.collectWithCache(data, exports.TrackerVariableType.BROWSER, exports.CacheStrategy.HEAVY, () => {
const b = BrowserDetector.getBrowser();
return `${b.name} ${b.version}`;
});
this.collectWithCache(data, exports.TrackerVariableType.OS, exports.CacheStrategy.HEAVY, () => {
const os = BrowserDetector.getOS();
return `${os.name} ${os.version}`;
});
this.collectWithCache(data, exports.TrackerVariableType.LANGUAGE, exports.CacheStrategy.MEDIUM, () => BrowserDetector.getLanguage());
this.collectWithCache(data, exports.TrackerVariableType.TIMEZONE, exports.CacheStrategy.MEDIUM, () => BrowserDetector.getTimezone());
this.collectWithCache(data, exports.TrackerVariableType.PLATFORM, exports.CacheStrategy.HEAVY, () => BrowserDetector.getPlatform());
this.collectWithCache(data, exports.TrackerVariableType.SUSPICIOUS_BROWSER, exports.CacheStrategy.SLOW, () => BrowserDetector.isSuspiciousBrowser());
this.collectWithCache(data, exports.TrackerVariableType.HEADLESS_INDICATOR_COUNT, exports.CacheStrategy.SLOW, () => BrowserDetector.getHeadlessIndicatorCount());
this.collectWithCache(data, exports.TrackerVariableType.THEME_MODE, exports.CacheStrategy.MEDIUM, () => window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "DARK" : "LIGHT");
// Non-cached (always fresh)
this.collectIfNeeded(data, exports.TrackerVariableType.SDK_VERSION, () => SDK_VERSION);
this.collectIfNeeded(data, exports.TrackerVariableType.TIMESTAMP, () => Date.now());
this.collectIfNeeded(data, exports.TrackerVariableType.ELAPSED_TIME, () => Date.now() - this.initTimestamp);
return data;
}
/**
* Returns the resolved fingerprint synchronously if available,
* otherwise returns the pending Promise.
*/
getFingerprint() {
if (this.resolvedFingerprint)
return this.resolvedFingerprint;
if (!this.fingerprintPromise) {
this.fingerprintPromise = FingerprintJS.load()
.then((fp) => fp.get())
.then((result) => {
this.resolvedFingerprint = result.visitorId;
return result.visitorId;
})
.catch(() => {
this.resolvedFingerprint = "unavailable";
return "unavailable";
});
}
return this.fingerprintPromise;
}
/**
* Pre-load fingerprint so it's ready when collect() is called.
* Must be awaited before the first event fires.
*/
async initFingerprint() {
const result = this.getFingerprint();
if (result instanceof Promise) {
const resolved = await result;
this.resolvedFingerprint = resolved;
this.invalidateCache(exports.TrackerVariableType.DEVICE_FINGERPRINT);
return resolved;
}
return result;
}
}
/**
* SDK logger. Disabled by default. Enable via `debug: true` in configuration.
* Mirrors Android Logger util.
*/
class Logger {
static setEnabled(enabled) {
Logger.enabled = enabled;
}
static debug(...args) {
if (Logger.enabled)
console.debug(Logger.PREFIX, ...args);
}
static info(...args) {
if (Logger.enabled)
console.info(Logger.PREFIX, ...args);
}
static warn(...args) {
if (Logger.enabled)
console.warn(Logger.PREFIX, ...args);
}
static error(...args) {
if (Logger.enabled)
console.error(Logger.PREFIX, ...args);
}
}
Logger.PREFIX = "[tracker]";
Logger.enabled = false;
class HttpError extends Error {
constructor(message, status) {
super(message);
this.status = status;
this.name = "HttpError";
}
}
/**
* Fetch API wrapper with timeout, JSON serialization, and error handling.
* Mirrors Android DefaultNetworkService HTTP layer.
*/
class HttpClient {
static async get(url, config) {
return HttpClient.request(url, { method: "GET", ...config });
}
static async post(url, body, config) {
return HttpClient.request(url, { method: "POST", body, ...config });
}
static async request(url, options) {
const controller = new AbortController();
const timeoutMs = options.timeout ?? 30000;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const headers = {
...options.headers,
};
// Only set Content-Type for requests with a body (POST/PUT/PATCH).
// Adding it to GET triggers CORS preflight on cross-origin APIs.
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
}
const fetchOptions = {
method: options.method,
headers,
signal: controller.signal,
};
if (options.body !== undefined) {
fetchOptions.body = JSON.stringify(options.body);
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new HttpError(`HTTP Error ${response.status}: ${response.statusText}`, response.status);
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return (await response.json());
}
return (await response.text());
}
catch (error) {
if (error instanceof HttpError)
throw error;
if (error instanceof DOMException && error.name === "AbortError") {
throw new HttpError(`Request timeout after ${timeoutMs}ms`, 408);
}
Logger.error("HTTP request failed:", error);
throw error;
}
finally {
clearTimeout(timeoutId);
}
}
}
const IP_API_URL = "https://api.ipify.org?format=json";
/**
* Collects network-related information.
* Mirrors Android NetworkCollector — adapted for web.
*/
class NetworkCollector extends CachedDataCollector {
constructor() {
super(...arguments);
this.resolvedIp = null;
}
collect() {
const data = new Map();
this.collectWithCache(data, exports.TrackerVariableType.IP_ADDRESS, exports.CacheStrategy.SLOW, () => this.resolvedIp ?? "unknown");
this.collectIfNeeded(data, exports.TrackerVariableType.REACHABLE, () => navigator.onLine);
return data;
}
/**
* Fetch the client's public IP address.
* Called during initialization; result is cached.
*/
async fetchIp() {
try {
const response = await HttpClient.get(IP_API_URL, { timeout: 5000 });
this.resolvedIp = response.ip;
return this.resolvedIp;
}
catch (error) {
Logger.warn("Failed to fetch IP address:", error);
this.resolvedIp = "unknown";
return this.resolvedIp;
}
}
}
/**
* Collects screen/page activity variables: URL parts, cookies, elements, JS evaluation, view duration.
* Mirrors Android ScreenActivityCollector + web-specific variable resolution from tracker-web_current.
*/
class ScreenActivityCollector extends BaseDataCollector {
constructor() {
super(...arguments);
this.viewDurationMs = 0;
this.timerIntervalId = null;
}
collect() {
const data = new Map();
this.collectIfNeeded(data, exports.TrackerVariableType.VIEW_DURATION, () => this.viewDurationMs);
return data;
}
/**
* Resolve a variable from the current page context using the backend config's variable config.
* This handles URL, ELEMENT, COOKIE, JAVASCRIPT, EVENT types.
*/
resolveVariable(varConfig, eventData) {
try {
switch (varConfig.type) {
case exports.TrackerVariableType.URL:
return this.resolveUrl(varConfig.option);
case exports.TrackerVariableType.ELEMENT:
return this.resolveElement(varConfig.option);
case exports.TrackerVariableType.COOKIE:
return this.resolveCookie(varConfig.option);
case exports.TrackerVariableType.JAVASCRIPT:
return this.resolveJavascript(varConfig.option);
case exports.TrackerVariableType.EVENT:
return this.resolveEvent(varConfig, eventData);
default:
return null;
}
}
catch (error) {
Logger.warn(`Failed to resolve variable ${varConfig.name}:`, error);
return null;
}
}
// === Timer (view duration) ===
startTimer() {
if (this.timerIntervalId !== null)
return;
this.viewDurationMs = 0;
this.timerIntervalId = setInterval(() => {
this.viewDurationMs += 100;
}, 100);
}
stopTimer() {
if (this.timerIntervalId !== null) {
clearInterval(this.timerIntervalId);
this.timerIntervalId = null;
}
}
resetTimer() {
this.viewDurationMs = 0;
}
// === URL Resolution ===
resolveUrl(option) {
const selection = option?.selection ?? "full";
const url = new URL(window.location.href);
switch (selection) {
case "host":
return url.hostname;
case "port":
return url.port;
case "path":
return url.pathname;
case "query":
return url.search;
case "fragment":
return url.hash;
case "protocol":
return url.protocol;
case "full":
default:
return url.href;
}
}
// === Element Resolution ===
resolveElement(option) {
if (!option?.cssSelector)
return null;
const el = document.querySelector(option.cssSelector);
if (!el)
return null;
if (option.attribute) {
return el.getAttribute(option.attribute);
}
return el.textContent;
}
// === Cookie Resolution ===
resolveCookie(option) {
if (!option?.cookieName)
return null;
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [name, ...valueParts] = cookie.split("=");
if (name.trim() === option.cookieName) {
const value = valueParts.join("=").trim();
return option.decodeUrlCookie ? decodeURIComponent(value) : value;
}
}
return null;
}
// === JavaScript Resolution (using new Function instead of eval) ===
resolveJavascript(option) {
if (!option?.code)
return null;
try {
// Use new Function() instead of eval() for slightly better isolation
return new Function(`return (${option.code})`)();
}
catch (error) {
Logger.warn("JavaScript variable evaluation failed:", error);
return null;
}
}
// === Event Resolution ===
resolveEvent(varConfig, eventData) {
if (!eventData)
return null;
return eventData[varConfig.name] ?? null;
}
/** Resolve all config-driven variables for a given config item. */
resolveConfigVariables(config, eventData) {
const data = new Map();
for (const varConfig of config.variables) {
const type = varConfig.type;
// Only resolve DOM/page-context types here
if (type === exports.TrackerVariableType.URL ||
type === exports.TrackerVariableType.ELEMENT ||
type === exports.TrackerVariableType.COOKIE ||
type === exports.TrackerVariableType.JAVASCRIPT ||
type === exports.TrackerVariableType.EVENT) {
const value = this.resolveVariable(varConfig, eventData);
if (value !== null && value !== undefined) {
data.set(type, value);
}
}
}
return data;
}
destroy() {
this.stopTimer();
}
}
const BASELINE_KEY_PREFIX = "formica_baseline_";
/**
* Collects security & risk metrics.
* Mirrors Android SecurityCollector + merges tracker-web_current risk-metrics + remote control detection.
*
* Responsibilities:
* 1. Remote control suspicion monitoring (mouse, clipboard, FPS, delayed clicks)
* 2. Risk metrics aggregation (mouse movement, click patterns, form fill, navigation, paste ratio)
* 3. Environment baseline tracking (language, timezone, OS, browser)
* 4. Composite remote access score calculation
*/
class SecurityCollector extends CachedDataCollector {
constructor() {
super(...arguments);
this.suspiciousFlags = {
unnaturalMouseMoves: false,
bigClipboardPaste: false,
lowFPSDetected: false,
delayedClickDetected: false,
};
// Risk metrics state
this.lastMouseMoveTs = Date.now();
this.mouseMoveCounterWindow = 0;
this.clickCounterWindow = 0;
this.pageViewTimestamps = [];
this.pasteCharCount = 0;
this.typedCharCount = 0;
this.lastPageViewTs = null;
this.lastFormInteractionStart = null;
this.lastFormFieldCount = 0;
this.lastFormDurationMs = 0;
this.formFillAnomaly = false;
this.fastPageTransition = false;
this.redirectSource = null;
this.lastUrl = null;
// Remote control monitoring state
this.lastMouseX = null;
this.lastMouseY = null;
this.unnaturalMoveCount = 0;
this.lowFPSCount = 0;
this.lastFrameTs = 0;
this.fpsRafId = null;
this.listenersAttached = false;
// Environment baseline
this.baseline = null;
this.envChangeFlags = null;
this.envChangeScore = 0;
// Tenant/device context (set during init)
this.tenant = "";
this.deviceId = "";
}
collect() {
const data = new Map();
const agg = this.getAggregates();
// Suspicious flags
this.collectWithCache(data, exports.TrackerVariableType.SUSPICIOUS_ACTIVITY, exports.CacheStrategy.FAST, () => ({
...this.suspiciousFlags,
}));
// Risk aggregates
this.collectIfNeeded(data, exports.TrackerVariableType.NO_MOUSE_MOVE_DURATION_MS, () => agg.noMouseMoveDurationMs);
this.collectIfNeeded(data, exports.TrackerVariableType.CLICK_ONLY_PATTERN, () => agg.clickOnlyPattern);
this.collectIfNeeded(data, exports.TrackerVariableType.FAST_PAGE_TRANSITION, () => agg.fastPageTransition);
this.collectIfNeeded(data, exports.TrackerVariableType.NAVIGATION_VELOCITY, () => agg.navigationVelocity);
this.collectIfNeeded(data, exports.TrackerVariableType.FORM_FILL_ANOMALY, () => agg.formFillAnomaly);
this.collectIfNeeded(data, exports.TrackerVariableType.FORM_FILL_DURATION_MS, () => agg.formFillDurationMs);
this.collectIfNeeded(data, exports.TrackerVariableType.INPUT_PASTE_RATIO, () => agg.inputPasteRatio);
this.collectIfNeeded(data, exports.TrackerVariableType.REDIRECT_DETECTED, () => agg.redirectSource);
// Environment change
this.collectIfNeeded(data, exports.TrackerVariableType.ENVIRONMENT_CHANGE_FLAGS, () => this.envChangeFlags ? { ...this.envChangeFlags } : null);
this.collectIfNeeded(data, exports.TrackerVariableType.ENVIRONMENT_CHANGE_SCORE, () => this.envChangeScore);
// Composite scores
const headlessCount = BrowserDetector.getHeadlessIndicatorCount();
const remoteAccessScore = this.calcRemoteAccessScore(headlessCount);
this.collectIfNeeded(data, exports.TrackerVariableType.REMOTE_ACCESS_SCORE, () => remoteAccessScore);
this.collectIfNeeded(data, exports.TrackerVariableType.REMOTE_ACCESS_FLAGS, () => ({
...this.suspiciousFlags,
clickOnlyPattern: agg.clickOnlyPattern,
headlessIndicatorCount: headlessCount,
environmentMismatch: this.envChangeScore > 0,
}));
return data;
}
// === Initialization ===
/**
* Start monitoring. Called once during SDK initialization.
*/
init(tenant, deviceId) {
this.tenant = tenant;
this.deviceId = deviceId;
this.attachListeners();
this.ensureBaseline();
}
// === Page view tracking ===
registerPageView() {
const now = Date.now();
this.fastPageTransition = this.lastPageViewTs !== null && now - this.lastPageViewTs < 1500;
this.lastPageViewTs = now;
this.pageViewTimestamps.push(now);
// Keep last 5 minutes only
const cutoff = now - 5 * 60 * 1000;
this.pageViewTimestamps = this.pageViewTimestamps.filter((t) => t >= cutoff);
this.detectRedirect();
}
// === Risk score ===
getRemoteAccessScore() {
return this.calcRemoteAccessScore(BrowserDetector.getHeadlessIndicatorCount());
}
resetRollingWindow() {
this.mouseMoveCounterWindow = 0;
this.clickCounterWindow = 0;
}
// === Environment baseline ===
updateEnvironmentDiff() {
if (!this.baseline)
return;
const current = this.getCurrentEnvironment();
this.envChangeFlags = this.diffEnvironment(this.baseline, current);
this.envChangeScore = this.calcEnvironmentChangeScore(this.envChangeFlags);
}
// === Internal ===
getAggregates() {
const now = Date.now();
const noMouseMoveDurationMs = now - this.lastMouseMoveTs;
const clickOnlyPattern = noMouseMoveDurationMs > 10000 && this.clickCounterWindow >= 3 && this.mouseMoveCounterWindow < 2;
const navigationVelocity = this.pageViewTimestamps.length / 5;
const inputPasteRatio = this.computePasteRatio();
return {
noMouseMoveDurationMs,
clickOnlyPattern,
fastPageTransition: this.fastPageTransition,
navigationVelocity,
formFillAnomaly: this.formFillAnomaly,
formFillDurationMs: this.lastFormDurationMs,
inputPasteRatio,
redirectSource: this.redirectSource,
};
}
computePasteRatio() {
const total = this.pasteCharCount + this.typedCharCount;
if (total === 0)
return 0;
return this.pasteCharCount / total;
}
calcRemoteAccessScore(headlessIndicatorCount) {
const agg = this.getAggregates();
let score = 0;
if (this.suspiciousFlags.unnaturalMouseMoves)
score += 0.2;
if (this.suspiciousFlags.bigClipboardPaste)
score += 0.15;
if (this.suspiciousFlags.lowFPSDetected)
score += 0.15;
if (this.suspiciousFlags.delayedClickDetected)
score += 0.1;
if (agg.clickOnlyPattern)
score += 0.15;
if (headlessIndicatorCount > 0)
score += Math.min(headlessIndicatorCount * 0.05, 0.25);
if (this.envChangeScore > 0)
score += 0.2;
return Math.min(1, score);
}
// === Remote control suspicion listeners ===
attachListeners() {
if (this.listenersAttached)
return;
this.listenersAttached = true;
// Mouse move — risk metrics + unnatural detection
window.addEventListener("mousemove", (e) => {
this.lastMouseMoveTs = Date.now();
this.mouseMoveCounterWindow++;
if (this.lastMouseX !== null && this.lastMouseY !== null) {
const dx = Math.abs(e.clientX - this.lastMouseX);
const dy = Math.abs(e.clientY - this.lastMouseY);
if (dx > 100 || dy > 100 || (dx === 0 && dy === 0))
this.unnaturalMoveCount++;
if (this.unnaturalMoveCount > 5)
this.suspiciousFlags.unnaturalMouseMoves = true;
}
this.lastMouseX = e.clientX;
this.lastMouseY = e.clientY;
});
// Click counter
document.addEventListener("click", () => {
this.clickCounterWindow++;
// Delayed click detection
const start = performance.now();
setTimeout(() => {
const delay = performance.now() - start;
if (delay > 150)
this.suspiciousFlags.delayedClickDetected = true;
}, 0);
});
// Paste detection
document.addEventListener("paste", (e) => {
const ce = e;
const text = ce.clipboardData?.getData("text") ?? "";
this.pasteCharCount += text.length;
if (text.length > 5000)
this.suspiciousFlags.bigClipboardPaste = true;
});
// Keydown counting
document.addEventListener("keydown", (e) => {
if (e.key.length === 1)
this.typedCharCount++;
});
// Form tracking
document.addEventListener("focusin", (e) => {
const form = e.target?.closest("form");
if (!form)
return;
if (this.lastFormInteractionStart === null) {
this.lastFormInteractionStart = Date.now();
this.lastFormFieldCount = form.querySelectorAll("input,textarea,select").length;
}
});
document.addEventListener("submit", (e) => {
if (!e.target)
return;
if (this.lastFormInteractionStart) {
this.lastFormDurationMs = Date.now() - this.lastFormInteractionStart;
this.formFillAnomaly = this.detectFormAnomaly(this.lastFormDurationMs, this.lastFormFieldCount);
}
this.lastFormInteractionStart = null;
});
// FPS monitoring
this.lastFrameTs = performance.now();
const monitorFPS = () => {
const now = performance.now();
const delta = now - this.lastFrameTs;
if (delta > 250)
this.lowFPSCount++;
if (this.lowFPSCount > 10)
this.suspiciousFlags.lowFPSDetected = true;
this.lastFrameTs = now;
this.fpsRafId = requestAnimationFrame(monitorFPS);
};
this.fpsRafId = requestAnimationFrame(monitorFPS);
}
detectFormAnomaly(durationMs, fieldCount) {
if (fieldCount === 0)
return false;
const adaptiveThreshold = Math.min(fieldCount * 120, 1500);
return durationMs < adaptiveThreshold;
}
detectRedirect() {
try {
const currentUrl = window.location.href;
if (this.lastUrl && this.lastUrl !== currentUrl) {
if (document.referrer && document.referrer !== currentUrl) {
try {
const referrerUrl = new URL(document.referrer);
const currentUrlObj = new URL(currentUrl);
this.redirectSource = referrerUrl.origin !== currentUrlObj.origin ? referrerUrl.origin : "internal";
}
catch {
this.redirectSource = document.referrer;
}
}
else if (!document.referrer) {
this.redirectSource = "direct";
}
else {
this.redirectSource = null;
}
}
else {
this.redirectSource = null;
}
this.lastUrl = currentUrl;
}
catch {
this.redirectSource = null;
}
}
// === Baseline ===
ensureBaseline() {
try {
const key = `${BASELINE_KEY_PREFIX}${this.tenant}_${this.deviceId}`;
const raw = localStorage.getItem(key);
if (raw) {
this.baseline = JSON.parse(raw);
}
else {
const current = this.getCurrentEnvironment();
this.baseline = { ...current, firstSeen: Date.now() };
localStorage.setItem(key, JSON.stringify(this.baseline));
}
this.updateEnvironmentDiff();
}
catch {
Logger.warn("localStorage unavailable for environment baseline");
}
}
getCurrentEnvironment() {
const browser = BrowserDetector.getBrowser();
const os = BrowserDetector.getOS();
return {
language: BrowserDetector.getLanguage(),
timezone: BrowserDetector.getTimezone(),
os: `${os.name} ${os.version}`,
browser: `${browser.name} ${browser.version}`,
};
}
diffEnvironment(baseline, current) {
return {
languageChanged: baseline.language !== current.language,
timezoneChanged: baseline.timezone !== current.timezone,
osChanged: baseline.os !== current.os,
browserChanged: baseline.browser !== current.browser,
};
}
calcEnvironmentChangeScore(flags) {
let score = 0;
if (flags.languageChanged)
score += 0.25;
if (flags.timezoneChanged)
score += 0.25;
if (flags.osChanged)
score += 0.2;
if (flags.browserChanged)
score += 0.3;
return Math.min(1, score);
}
/** Cleanup FPS monitor on termination. */
destroy() {
if (this.fpsRafId !== null) {
cancelAnimationFrame(this.fpsRafId);
this.fpsRafId = null;
}
}
}
/**
* Web-specific event types that trigger data collection.
* Mirrors Android TrackerEventType but adapted for browser environment.
*/
exports.TrackerEventType = void 0;
(function (TrackerEventType) {
TrackerEventType["PAGE_VIEW"] = "PAGE_VIEW";
TrackerEventType["CLICK"] = "CLICK";
TrackerEventType["SCROLL"] = "SCROLL";
TrackerEventType["CUSTOM"] = "CUSTOM";
TrackerEventType["RISK_ALERT"] = "RISK_ALERT";
TrackerEventType["VISIBILITY_CHANGE"] = "VISIBILITY_CHANGE";
TrackerEventType["IDLE"] = "IDLE";
TrackerEventType["FORM_INTERACTION"] = "FORM_INTERACTION";
TrackerEventType["COPY_PASTE"] = "COPY_PASTE";
})(exports.TrackerEventType || (exports.TrackerEventType = {}));
/**
* Listens for click events on the document.
* Uses a cooldown to prevent excessive event triggering.
* Mirrors Android TouchListener.
*/
class ClickListener {
constructor(trigger, cooldownMs = 1000) {
this.started = false;
this.boundHandler = null;
this.lastTriggerTs = 0;
this.trigger = trigger;
this.cooldownMs = cooldownMs;
}
start() {
if (this.started)
return;
this.started = true;
this.boundHandler = () => {
const now = Date.now();
if (now - this.lastTriggerTs >= this.cooldownMs) {
this.lastTriggerTs = now;
this.trigger(exports.TrackerEventType.CLICK);
}
};
document.addEventListener("click", this.boundHandler);
}
stop() {
if (!this.started)
return;
this.started = false;
if (this.boundHandler) {
document.removeEventListener("click", this.boundHandler);
this.boundHandler = null;
}
}
getTypes() {
return [exports.TrackerEventType.CLICK];
}
getVariableTypes() {
return [exports.TrackerVariableType.ELEMENT];
}
}
/**
* Triggers COPY_PASTE event when the user pastes content.
* Uses cooldown to prevent spam from rapid paste operations.
*/
class CopyPasteListener {
constructor(trigger, cooldownMs = 2000) {
this.started = false;
this.boundHandler = null;
this.lastTriggerTs = 0;
this.trigger = trigger;
this.cooldownMs = cooldownMs;
}
start() {
if (this.started)
return;
this.started = true;
this.boundHandler = () => {
const now = Date.now();
if (now - this.lastTriggerTs >= this.cooldownMs) {
this.lastTriggerTs = now;
this.trigger(exports.TrackerEventType.COPY_PASTE);
}
};
document.addEventListener("paste", this.boundHandler);
}
stop() {
if (!this.started)
return;
this.started = false;
if (this.boundHandler) {
document.removeEventListener("paste", this.boundHandler);
this.boundHandler = null;
}
}
getTypes() {
return [exports.TrackerEventType.COPY_PASTE];
}
getVariableTypes() {
return [exports.TrackerVariableType.INPUT_PASTE_RATIO];
}
}
/**
* Triggers FORM_INTERACTION event on form focus-in and submit.
* Uses cooldown to prevent excessive triggering when user tabs
* between multiple form fields rapidly.
*/
class FormInteractionListener {
constructor(trigger, cooldownMs = 3000) {
this.started = false;
this.boundFocusHandler = null;
this.boundSubmitHandler = null;
this.lastTriggerTs = 0;
this.trigger = trigger;
this.cooldownMs = cooldownMs;
}
start() {
if (this.started)
return;
this.started = true;
this.boundFocusHandler = (e) => {
const target = e.target;
if (!target)
return;
const isFormElement = target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement;
if (isFormElement) {
this.triggerWithCooldown();
}
};
this.boundSubmitHandler = () => {
this.triggerWithCooldown();
};
document.addEventListener("focusin", this.boundFocusHandler, { passive: true });
document.addEventListener("submit", this.boundSubmitHandler, { passive: true });
}
stop() {
if (!this.started)
return;
this.started = false;
if (this.boundFocusHandler) {
document.removeEventListener("focusin", this.boundFocusHandler);
this.boundFocusHandler = null;
}
if (this.boundSubmitHandler) {
document.removeEventListener("submit", this.boundSubmitHandler);
this.boundSubmitHandler = null;
}
}
getTypes() {
return [exports.TrackerEventType.FORM_INTERACTION];
}
getVariableTypes() {
return [exports.TrackerVariableType.FORM_FILL_ANOMALY, exports.TrackerVariableType.FORM_FILL_DURATION_MS];
}
triggerWithCooldown() {
const now = Date.now();
if (now - this.lastTriggerTs >= this.cooldownMs) {
this.lastTriggerTs = now;
this.trigger(exports.TrackerEventType.FORM_INTERACTION);
}
}
}
const ACTIVITY_EVENTS = ["mousemove", "keydown", "scroll", "touchstart", "click"];
/**
* Triggers IDLE event when the user has been inactive for a configurable duration.
* Resets the timer on any user interaction. Uses cooldown to avoid repeated
* IDLE events during prolonged inactivity.
*/
class IdleListener {
constructor(trigger, idleThresholdMs = 30000, cooldownMs = 60000) {
this.started = false;
this.idleTimer = null;
this.boundActivityHandler = null;
this.lastTriggerTs = 0;
this.trigger = trigger;
this.idleThresholdMs = idleThresholdMs;
this.cooldownMs = cooldownMs;
}
start() {
if (this.started)
return;
this.started = true;
this.boundActivityHandler = () => this.resetTimer();
for (const event of ACTIVITY_EVENTS) {
document.addEventListener(event, this.boundActivityHandler, { passive: true });
}
this.resetTimer();
}
stop() {
if (!this.started)
return;
this.started = false;
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
if (this.boundActivityHandler) {
for (const event of ACTIVITY_EVENTS) {
document.removeEventListener(event, this.boundActivityHandler);
}
this.boundActivityHandler = null;
}
}
getTypes() {
return [exports.TrackerEventType.IDLE];
}
getVariableTypes() {
return [];
}
resetTimer() {
if (this.idleTimer)
clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => {
const now = Date.now();
if (now - this.lastTriggerTs >= this.cooldownMs) {
this.lastTriggerTs = now;
this.trigger(exports.TrackerEventType.IDLE);
}
}, this.idleThresholdMs);
}
}
/**
* Listens for page navigation events (popstate, pushState, replaceState).
* Mirrors Android ActivityLifecycleListener for PAGE_VIEW.
*/
class PageViewListener {
constructor(trigger) {
this.started = false;
this.boundPopstate = null;
this.origPushState = null;
this.origReplaceState = null;
this.trigger = trigger;
}
start() {
if (this.started)
return;
this.started = true;
// popstate
this.boundPopstate = () => this.trigger(exports.TrackerEventType.PAGE_VIEW);
window.addEventListener("popstate", this.boundPopstate);
// Patch pushState/replaceState to detect SPA navigation
this.origPushState = history.pushState.bind(history);
this.origReplaceState = history.replaceState.bind(history);
const origPush = this.origPushState;
const origReplace = this.origReplaceState;
const trigger = () => this.trigger(exports.TrackerEventType.PAGE_VIEW);
history.pushState = function (...args) {
if (origPush)
origPush(...args);
trigger();
};
history.replaceState = function (...args) {
if (origReplace)
origReplace(...args);
trigger();
};
}
stop() {
if (!this.started)
return;
this.started = false;
if (this.boundPopstate) {
window.removeEventListener("popstate", this.boundPopstate);
this.boundPopstate = null;
}
if (this.origPushState) {
history.pushState = this.origPushState;
this.origPushState = null;
}
if (this.origReplaceState) {
history.replaceState = this.origReplaceState;
this.origReplaceState = null;
}
}
getTypes() {
return [exports.TrackerEventType.PAGE_VIEW];
}
getVariableTypes() {
return [exports.TrackerVariableType.URL, exports.TrackerVariableType.VIEW_DURATION];
}
}
/**
* Listens for scroll events (debounced + cooldown).
* Uses debounce to wait for scroll to settle, then cooldown to prevent
* excessive event triggering during continuous scrolling.
*/
class ScrollListener {
constructor(trigger, debounceMs = 800, cooldownMs = 2000) {
this.started = false;
this.boundHandler = null;
this.debounceTimer = null;
this.lastTriggerTs = 0;
this.trigger = trigger;
this.debounceMs = debounceMs;
this.cooldownMs = cooldownMs;
}
start() {
if (this.started)
return;
this.started = true;
this.boundHandler = () => {
if (this.debounceTimer)
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
const now = Date.now();
if (now - this.lastTriggerTs >= this.cooldownMs) {
this.lastTriggerTs = now;
this.trigger(exports.TrackerEventType.SCROLL);
}
}, this.debounceMs);
};
window.addEventListener("scroll", this.boundHandler, { passive: true });
}
stop() {
if (!this.started)
return;
this.started = false;
if (this.boundHandler) {
window.removeEventListener("scroll", this.boundHandler);
this.boundHandler = null;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
}
getTypes() {
return [exports.TrackerEventType.SCROLL];
}
getVariableTypes() {
return [];
}
}
/**
* Listens for visibility change events (tab focus/blur).
* Mirrors Android AppStateListener.
*/
class VisibilityListener {
constructor(trigger) {
this.started = false;
this.boundHandler = null;
this.trigger = trigger;
}
start() {
if (this.started)
return;
this.started = true;
this.boundHandler = () => this.trigger(exports.TrackerEventType.VISIBILITY_CHANGE);
document.addEventListener("visibilitychange", this.boundHandler);
}
stop() {
if (!this.started)
return;
this.started = false;
if (this.boundHandler) {
document.removeEventListener("visibilitychange", this.boundHandler);
this.boundHandler = null;
}
}
getTypes() {
return [exports.TrackerEventType.VISIBILITY_CHANGE];
}
getVariableTypes() {
return [exports.TrackerVariableType.VIEW_DURATION];
}
}
/**
* Filter operators for variable filtering.
* Identical to Android/iOS FilterOperator — full set of 24 operators.
*/
exports.FilterOperator = void 0;
(function (FilterOperator) {
FilterOperator["IS_EQUALS"] = "isEquals";
FilterOperator["IS_EQUALS_IGNORE_CASE"] = "isEqualsIgnoreCase";
FilterOperator["NOT_EQUALS"] = "notEquals";
FilterOperator["NOT_EQUALS_IGNORE_CASE"] = "notEqualsIgnoreCase";
FilterOperator["IS_CONTAINS"] = "isContains";
FilterOperator["IS_CONTAINS_IGNORE_CASE"] = "isContainsIgnoreCase";
FilterOperator["NOT_CONTAINS"] = "notContains";
FilterOperator["NOT_CONTAINS_IGNORE_CASE"] = "notContainsIgnoreCase";
FilterOperator["IS_STARTS_WITH"] = "isStartsWith";
FilterOperator["IS_STARTS_WITH_IGNORE_CASE"] = "isStartsWithIgnoreCase";
FilterOperator["NOT_STARTS_WITH"] = "notStartsWith";
FilterOperator["NOT_STARTS_WITH_IGNORE_CASE"] = "notStartsWithIgnoreCase";
FilterOperator["IS_ENDS_WITH"] = "isEndsWith";
FilterOperator["IS_ENDS_WITH_IGNORE_CASE"] = "isEndsWithIgnoreCase";
FilterOperator["NOT_ENDS_WITH"] = "notEndsWith";
FilterOperator["NOT_ENDS_WITH_IGNORE_CASE"] = "notEndsWithIgnoreCase";
FilterOperator["IS_REGEX_MATCH"] = "isRegexMatch";
FilterOperator["NOT_REGEX_MATCH"] = "notRegexMatch";
FilterOperator["LESS_THAN"] = "lessThan";
FilterOperator["LESS_THAN_OR_EQUALS"] = "lessThanOrEquals";
FilterOperator["GREATER_THAN"] = "greaterThan";
FilterOperator["GREATER_THAN_OR_EQUALS"] = "greaterThanOrEquals";
})(exports.FilterOperator || (exports.FilterOperator = {}));
/**
* Applies filter conditions to variable values.
* Mirrors Android/iOS FilterEngine logic.
*
* Trigger filters use AND logic: ALL filters must pass.
* For arrays/iterables, the filter is applied to each element.
*/
class FilterEngine {
/**
* Returns true if ALL filters pass against the given variables.
*/
static passesTriggerFilters(filters, variables) {
if (!filters || filters.length === 0)
return true;
return filters.every((filter) => {
const value = variables.get(filter.field);
return FilterEngine.applyFilter(value, filter.operator, filter.value);
});
}
/**
* Apply a single filter operator to a field value.
*/
static applyFilter(fieldValue, operator, filterValue) {
if (fieldValue === null || fieldValue === undefined) {
// Null/undefined values only pass NOT operators
return FilterEngine.isNegationOperator(operator);
}
// Handle arrays: filter passes if ANY element matches
if (Array.isArray(fieldValue)) {
const anyMatch = fieldValue.some((item) => FilterEngine.applySingleFilter(String(item), operator, filterValue));
return FilterEngine.isNegationOperator(operator) ? !anyMatch : anyMatch;
}
return FilterEngine.applySingleFilter(String(fieldValue), operator, filterValue);
}
static applySingleFilter(value, operator, filterValue) {
switch (operator) {
case exports.FilterOperator.IS_EQUALS:
return value === filterValue;
case exports.FilterOperator.IS_EQUALS_IGNORE_CASE:
return value.toLowerCase() === filterValue.toLowerCase();
case exports.FilterOperator.NOT_EQUALS:
return value !== filterValue;
case exports.FilterOperator.NOT_EQUALS_IGNORE_CASE:
return value.toLowerCase() !== filterValue.toLowerCase();
case exports.FilterOperator.IS_CONTAINS:
return value.includes(filterValue);
case exports.FilterOperator.IS_CONTAINS_IGNORE_CASE:
return value.toLowerCase().includes(filterValue.toLowerCase());
case exports.FilterOperator.NOT_CONTAINS:
return !value.includes(filterValue);
case exports.FilterOperator.NOT_CONTAINS_IGNORE_CASE:
return !value.toLowerCase().includes(filterValue.toLowerCase());
case exports.FilterOperator.IS_STARTS_WITH:
return value.startsWith(filterValue);
case exports.FilterOperator.IS_STARTS_WITH_IGNORE_CASE:
return value.toLowerCase().startsWith(filterValue.toLowerCase());
case exports.FilterOperator.NOT_STARTS_WITH:
return !value.startsWith(filterValue);
case exports.FilterOperator.NOT_STARTS_WITH_IGNORE_CASE:
return !value.toLowerCase().startsWith(filterValue.toLowerCase());
case exports.FilterOperator.IS_ENDS_WITH:
return value.endsWith(filterValue);
case exports.FilterOperator.IS_ENDS_WITH_IGNORE_CASE:
return value.toLowerCase().endsWith(filterValue.toLowerCase());
case exports.FilterOperator.NOT_ENDS_WITH:
return !value.endsWith(filterValue);
case exports.FilterOperator.NOT_ENDS_WITH_IGNORE_CASE:
return !value.toLowerCase().endsWith(filterValue.toLowerCase());
case exports.FilterOperator.IS_REGEX_MATCH:
return new RegExp(filterValue).test(value);
case exports.FilterOperator.NOT_REGEX_MATCH:
return !new RegExp(filterValue).test(value);
case exports.FilterOperator.LESS_THAN:
return Number(value) < Number(filterValue);
case exports.FilterOperator.LESS_THAN_OR_EQUALS:
return Number(value) <= Number(filterValue);
case exports.FilterOperator.GREATER_THAN:
return Number(value) > Number(filterValue);
case exports.FilterOperator.GREATER_THAN_OR_EQUALS:
return Number(value) >= Number(filterValue);
default:
return false;
}
}
static isNegationOperator(operator) {
return (operator === exports.FilterOperator.NOT_EQUALS ||
operator === exports.FilterOperator.NOT_EQUALS_IGNORE_CASE ||
operator === exports.FilterOperator.NOT_CONTAINS ||
operator === exports.FilterOperator.NOT_CONTAINS_IGNORE_CASE ||
operator === exports.FilterOperator.NOT_STARTS_WITH ||
operator === exports.FilterOperator.NOT_STARTS_WITH_IGNORE_CASE ||
operator === exports.FilterOperator.NOT_ENDS_WITH ||
operator === exports.FilterOperator.NOT_ENDS_WITH_IGNORE_CASE ||
operator === exports.FilterOperator.NOT_REGEX_MATCH);
}
}
/**
* Coordinates native event listeners and manages their lifecycle.
* Mirrors Android EventCoordinator — adapted for browser environment.
*/
class EventCoordinator {
constructor(eventService, collectors, payload, screenActivityCollector, securityCollector) {
this.listeners = [];
this.userDefinedValuesProvider = null;
this.stopped = true;
this.nativeEventListener = null;
// RISK_ALERT auto-trigger state
this.riskAlertIntervalId = null;
this.lastRiskAlertTs = 0;
this.eventService = eventService;
this.collectors = collectors;
this.payload = payload;
this.screenActivityCollector = screenActivityCollector;
this.securityCollector = securityCollector;
}
setUserDefinedValuesProvider(provider) {
this.userDefinedValuesProvider = provider;
}
start() {
Logger.debug("Starting EventCoordinator");
this.stopped = false;
this.initializeListeners();
this.startRiskAlertEvaluator();
// Trigger initial PAGE_VIEW event
this.triggerNativeEvent(exports.TrackerEventType.PAGE_VIEW);
Logger.debug("EventCoordinator started with", this.listeners.length, "listeners");
}
stop() {
Logger.debug("Stopping EventCoordinator");
this.stopped = true;
if (this.riskAlertIntervalId !== null) {
clearInterval(this.riskAlertIntervalId);
this.riskAlertIntervalId = null;
}
for (const listener of this.listeners) {
try {
listener.stop();
}
catch (error) {
Logger.error("Error stopping listener:", error);
}
}
this.listeners = [];
this.nativeEventListener = null;
Logger.debug("EventCoordinator stopped");
}
setNativeEventListener(listener) {
this.nativeEventListener = listener;
}
collectAllVariablesNow() {
const allVariables = new Map();
// Get variables from payload
const payloadVars = this.payload.getCollectedVariables();
for (const [k, v] of payloadVars) {
allVariables.set(k, v);
}
// Collect from all data collectors
this.collectFromAllCollectors(allVariables);
return allVariables;
}
triggerNativeEvent(type) {
if (this.stopped) {
Logger.warn("Ignoring native event (coordinator stopped):", type);
return;
}
try {
this.triggerEventInternal(type, null, null);
}
catch (error) {
Logger.error("Error in triggerNativeEvent:", error);
}
}
triggerCustomEvent(eventName, args) {
try {
this.triggerEventInternal(exports.TrackerEventType.CUSTOM, eventName, args);
}
catch (error) {
Logger.error("Error in triggerCustomEvent:", error);
}
}
// === Internal ===
triggerEventInternal(eventType, customEventName, customArguments) {
const rootConfig = this.payload.getTrackerConfig();
const isCustomEvent = eventType === exports.TrackerEventType.CUSTOM;
if (!rootConfig) {
Logger.error("Config not initialized, cannot trigger event");
return;
}
Logger.debug(isCustomEvent ? `Custom event triggered: ${customEventName}` : `Native event triggered: ${eventType}`);
// Register page view for risk metrics
if (eventType === exports.TrackerEventType.PAGE_VIEW) {
this.securityCollector.registerPageView();
this.screenActivityCollector.resetTimer();
this.screenActivityCollector.startTimer();
}
// Collect all variables
const allConfigVariables = this.collectAllConfigVariables();
// Inject user-defined values into CUSTOM_TRIGGER_ARGS for all events
const userValues = this.userDefinedValuesProvider?.() ?? {};
if (isCustomEvent && customEventName) {
// Merge: custom arguments take precedence, then user-defined values
const merged = { ...userValues, ...customArguments };
if (Object.keys(merged).length > 0) {
allConfigVariables.set(exports.TrackerVariableType.CUSTOM_TRIGGER_ARGS, merged);
}
}
else if (Object.keys(userValues).length > 0) {
allConfigVariables.set(exports.TrackerVariableType.CUSTOM_TRIGGER_ARGS, userValues);
}
// Process each config
for (const config of rootConfig.configs) {
try {
const triggerConfig = this.findMatchingTrigger(config, eventType, customEventName);
if (!triggerConfig)
continue;
// Set current trigger name
allConfigVariables.set(exports.TrackerVariableType.CURRENT_TRIGGER_NAME, triggerConfig.name);
// Check trigger filters
if (!this.passesTriggerFilters(config, triggerConfig, allConfigVariables)) {
Logger.debug("Trigger", triggerConfig.name, "blocked by filters");
continue;
}
// Resolve screen activity variables (URL, Element, Cookie, JS)
const screenVars = this.screenActivityCollector.resolveConfigVariables(config);
for (const [k, v] of screenVars) {
allConfigVariables.set(k, v);
}
// Map variables to backend fields
const variables = BaseDataCollector.mapVariablesToBackend(config, allConfigVariables);
// Create and enqueue event
const deviceId = this.payload.getDeviceId();
const event = {
name: config.event.name,
key: deviceId,
variables,
};
this.eventService.enqueueEvent(event);
Logger.debug("Event enqueued:", config.event.name);
}
catch (error) {
Logger.error("Error processing config for event:", error);
}
}
// Notify external listener
if (this.nativeEventListener) {
try {
const eventData = {
type: eventType,
args: Object.fromEntries(allConfigVariables),
timestamp: Date.now(),
};
this.nativeEventListener.onNativeEvent(eventData);
}
catch (error) {
Logger.error("Error notifying native event listener:", error);
}
}
}
collectAllConfigVariables() {
const variables = new Map();
// Get payload variables
const payloadVars = this.payload.getCollectedVariables();
for (const [k, v] of payloadVars) {
variables.set(k, v);
}
// Collect from all data collectors
this.collectFromAllCollectors(variables);
return variables;
}
collectFromAllCollectors(target) {
for (const collector of this.collectors) {
try {
const collectedData = collector.collect();
for (const [k, v] of collectedData) {
target.set(k, v);
}
}
catch (error) {
Logger.error("Error collecting from collector:", error);
}
}
}
findMatchingTrigger(config, eventType, customEventName) {
if (eventType === exports.TrackerEventType.CUSTOM && customEventName) {
return config.triggers.find((t) => t.type === exports.TrackerEventType.CUSTOM && t.name === customEventName) ?? null;
}
return config.triggers.find((t) => t.type === eventType) ?? null;
}
passesTriggerFilters(config, trigger, variables) {
if (!trigger.filters || trigger.filters.length === 0)
return true;
// Build a string-keyed map for filter matching
const filterData = new Map();
for (const varConfig of config.variables) {
const type = varConfig.type;
const value = variables.get(type);
if (value !== undefined) {
filterData.set(varConfig.name, value);
}
}
return FilterEngine.passesTriggerFilters(trigger.filters, filterData);
}
initializeListeners() {
const config = this.payload.getTrackerConfig();
if (!config) {
Logger.error("Config not initialized, cannot initialize listeners");
return;
}
const triggerFn = (type) => this.triggerNativeEvent(type);
// Register all web listeners
this.listeners.push(new PageViewListener(triggerFn));
this.listeners.push(new ClickListener(triggerFn));
this.listeners.push(new ScrollListener(triggerFn));
this.listeners.push(new VisibilityListener(triggerFn));
this.listeners.push(new IdleListener(triggerFn));
this.listeners.push(new FormInteractionListener(triggerFn));
this.listeners.push(new CopyPasteListener(triggerFn));
// Start only listeners whose event types have triggers in config
for (const listener of this.listeners) {
const hasTriggers = listener
.getTypes()
.some((type) => config.configs.some((c) => c.triggers.some((t) => t.type === type)));
const hasVariables = listener
.getVariableTypes()
.some((varType) => config.configs.some((c) => Object.values(c.event.variableMappings).includes(varType)));
if (!hasTriggers && !hasVariables) {
Logger.debug("Skipping listener (not configured):", listener.constructor.name);
continue;
}
try {
listener.start();
Logger.debug("Started listener:", listener.constructor.name);
}
catch (error) {
Logger.error("Failed to start listener:", listener.constructor.name, error);
}
}
}
startRiskAlertEvaluator() {
const config = this.payload.getTrackerConfig();
if (!config)
return;
// Only run if RISK_ALERT triggers exist in config
const hasRiskAlertTrigger = config.configs.some((c) => c.triggers.some((t) => t.type === exports.TrackerEventType.RISK_ALERT));
if (!hasRiskAlertTrigger)
return;
this.riskAlertIntervalId = setInterval(() => {
this.evaluateRiskAlert();
}, 30000);
}
evaluateRiskAlert() {
if (this.stopped)
return;
// 60s cooldown
if (Date.now() - this.lastRiskAlertTs < 60000)
return;
const vars = this.collectAllConfigVariables();
const biometricScore = vars.get(exports.TrackerVariableType.BEHAVIORAL_BIOMETRIC_SCORE) ?? 0;
const remoteAccessScore = vars.get(exports.TrackerVariableType.REMOTE_ACCESS_SCORE) ?? 0;
const combinedScore = biometricScore * 0.6 + remoteAccessScore * 0.4;
if (combinedScore > 0.6) {
this.lastRiskAlertTs = Date.now();
Logger.debug("RISK_ALERT triggered (combined score:", combinedScore, ")");
this.triggerNativeEvent(exports.TrackerEventType.RISK_ALERT);
}
}
}
/**
* Core engine that coordinates all tracker functionality.
* Mirrors Android TrackerEngine.
*/
class TrackerEngine {
constructor(eventService, eventCoordinator, networkService, payload, scheduler, deviceCollector, networkCollector, securityCollector, behavioralCollector, screenActivityCollector) {
this.userDefinedValues = {};
this.isConfigLoaded = false;
this.eventService = eventService;
this.eventCoordinator = eventCoordinator;
this.networkService = networkService;
this.payload = payload;
this.scheduler = scheduler;
this.deviceCollector = deviceCollector;
this.networkCollector = networkCollector;
this.securityCollector = securityCollector;
this.behavioralCollector = behavioralCollector;
this.screenActivityCollector = screenActivityCollector;
// Wire user-defined values provider to EventCoordinator
this.eventCoordinator.setUserDefinedValuesProvider(() => ({ ...this.userDefinedValues }));
}
async initialize(callback) {
Logger.debug("Initializing TrackerEngine");
try {
const config = await this.networkService.fetchConfiguration();
if (!config) {
callback.onComplete(false, "Config fetch returned null");
return;
}
this.payload.setTrackerConfig(config);
this.isConfigLoaded = true;
// Initialize collectors that need async setup
await Promise.all([this.deviceCollector.initFingerprint(), this.networkCollector.fetchIp()]);
// Initialize security collector with context
const configuration = this.payload.getConfiguration();
const deviceId = this.payload.getDeviceId();
this.securityCollector.init(configuration?.tenant ?? "", deviceId);
// Initialize behavioral collector
this.behavioralCollector.init();
// Start event coordinator (manages all listeners)
this.eventCoordinator.start();
// Start event service (queue processor)
this.eventService.start();
// Start periodic scheduler
this.scheduler.start();
// Start screen activity timer
this.screenActivityCollector.startTimer();
Logger.debug("TrackerEngine initialized successfully");
callback.onComplete(true, "Tracker initialized successfully");
}
catch (error) {
Logger.error("Failed to initialize:", error);
const message = error instanceof Error ? error.message : String(error);
callback.onComplete(false, `Failed to initialize: ${message}`);
}
}
triggerCustomEvent(eventName, args) {
if (!this.isConfigLoaded) {
Logger.error("Config not loaded, cannot trigger custom event");
return;
}
this.eventCoordinator.triggerCustomEvent(eventName, args);
}
async triggerCustomEventSync(eventName, args, callback) {
if (!this.isConfigLoaded) {
callback.onError(new Error("Config not loaded"));
return;
}
const mergedArgs = { ...this.userDefinedValues, ...args };
const config = this.payload.getTrackerConfig();
if (!config) {
callback.onError(new Error("Config not available"));
return;
}
const allConfigVariables = this.eventCoordinator.collectAllVariablesNow();
allConfigVariables.set(exports.TrackerVariableType.CUSTOM_TRIGGER_ARGS, mergedArgs);
for (const configItem of config.configs) {
try {
const trigger = configItem.triggers.find((t) => t.type === exports.TrackerEventType.CUSTOM && t.name === eventName);
if (!trigger)
continue;
allConfigVariables.set(exports.TrackerVariableType.CURRENT_TRIGGER_NAME, trigger.name);
// Resolve screen variables
const screenVars = this.screenActivityCollector.resolveConfigVariables(configItem);
for (const [k, v] of screenVars) {
allConfigVariables.set(k, v);
}
const variables = BaseDataCollector.mapVariablesToBackend(configItem, allConfigVariables);
const deviceId = this.payload.getDeviceId();
const event = {
name: configItem.event.name,
key: deviceId,
variables,
};
await this.eventService.sendEventSync(event, callback);
}
catch (error) {
Logger.error("Error creating event for config:", error);
callback.onError(error instanceof Error ? error : new Error(String(error)));
}
}
}
setUserDefinedValue(key, value) {
if (value === null || value === undefined) {
this.userDefinedValues[key] = undefined;
}
else {
this.userDefinedValues[key] = value;
}
}
getUserDefinedValues() {
return { ...this.userDefinedValues };
}
getAllVariables() {
return this.eventCoordinator.collectAllVariablesNow();
}
getConfiguration() {
return this.payload.getTrackerConfig();
}
triggerNativeEvent(eventType) {
this.eventCoordinator.triggerNativeEvent(eventType);
}
setNativeEventListener(listener) {
this.eventCoordinator.setNativeEventListener(listener);
}
terminate() {
Logger.debug("Terminating TrackerEngine");
this.isConfigLoaded = false;
try {
this.eventCoordinator.stop();
}
catch (e) {
Logger.error("Error stopping coordinator:", e);
}
try {
this.eventService.stop();
}
catch (e) {
Logger.error("Error stopping event service:", e);
}
try {
this.scheduler.stop();
}
catch (e) {
Logger.error("Error stopping scheduler:", e);
}
try {
this.securityCollector.destroy();
}
catch (e) {
Logger.error("Error destroying security:", e);
}
try {
this.behavioralCollector.destroy();
}
catch (e) {
Logger.error("Error destroying behavioral:", e);
}
try {
this.screenActivityCollector.destroy();
}
catch (e) {
Logger.error("Error destroying screen:", e);
}
// Clear user values
Object.keys(this.userDefinedValues).forEach((key) => {
this.userDefinedValues[key] = undefined;
});
Logger.debug("TrackerEngine terminated");
}
}
/**
* Periodic scheduler for background data collection tasks.
* Mirrors Android PeriodicDataScheduler.
*/
class PeriodicDataScheduler {
constructor(task, intervalMs = 5000) {
this.task = task;
this.intervalMs = intervalMs;
this.intervalId = null;
}
start() {
if (this.intervalId !== null)
return;
this.intervalId = setInterval(() => {
try {
this.task();
}
catch {
// Swallow errors — scheduler must not crash
}
}, this.intervalMs);
}
stop() {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
isRunning() {
return this.intervalId !== null;
}
}
/**
* Default network service implementation using Fetch API.
* Mirrors Android DefaultNetworkService.
*/
class DefaultNetworkService {
constructor(configuration) {
this.configuration = configuration;
}
async fetchConfiguration() {
const url = `${this.configuration.apiUrl}/sentinel/v1`;
Logger.debug("Fetching configuration from:", url);
const response = await HttpClient.get(url, {
headers: {
"api-key": this.configuration.apiKey,
tenant: this.configuration.tenant,
platform: "web",
},
timeout: this.configuration.timeout,
});
Logger.debug("Received configuration:", JSON.stringify(response, null, 2));
// Filter config: prefer WEB platform, fall back to first available
const webConfig = response.configs.find((c) => c.platform === "WEB");
const config = {
...response,
configs: webConfig ? [webConfig] : response.configs,
};
return config;
}
async sendEvent(event) {
// POST to same endpoint as config fetch (mirrors Android SDK behavior)
const url = `${this.configuration.apiUrl}/sentinel/v1`;
Logger.debug("Sending event:", event.name);
// Strip internal-only fields before sending
const { retryCount: _, ...payload } = event;
return HttpClient.post(url, payload, {
headers: {
"api-key": this.configuration.apiKey,
tenant: this.configuration.tenant,
platform: "web",
},
timeout: this.configuration.timeout,
});
}
isNetworkAvailable() {
return navigator.onLine;
}
}
/**
* Service responsible for event queuing and periodic processing.
* Mirrors Android EventService.
*/
class EventService {
constructor(eventStorage, networkService, configuration) {
this.scheduler = null;
this.consecutiveFailures = 0;
this.eventStorage = eventStorage;
this.networkService = networkService;
this.configuration = configuration;
}
start() {
Logger.debug("Starting EventService");
this.scheduler = new PeriodicDataScheduler(() => this.processQueue(), this.configuration.queueProcessInterval ?? 5000);
this.scheduler.start();
}
stop() {
Logger.debug("Stopping EventService");
this.scheduler?.stop();
this.scheduler = null;
}
enqueueEvent(event) {
Logger.debug("Enqueuing event:", event.name);
this.eventStorage.saveEvent(event);
}
async sendEventSync(event, callback) {
try {
Logger.debug("Sending event sync:", event.name);
const result = await this.networkService.sendEvent(event);
if (result) {
callback.onSuccess(result);
}
}
catch (error) {
Logger.error("Failed to send event:", event.name, error);
callback.onError(error instanceof Error ? error : new Error(String(error)));
}
}
async processQueue() {
try {
if (!this.networkService.isNetworkAvailable()) {
Logger.debug("Network not available, skipping queue processing");
return;
}
// Stop retrying after too many consecutive failures
if (this.consecutiveFailures >= EventService.MAX_CONSECUTIVE_FAILURES) {
Logger.debug("Too many consecutive failures, pausing queue processing");
return;
}
const pendingEvents = this.eventStorage.getPendingEvents();
if (pendingEvents.length === 0)
return;
Logger.debug("Processing queue:", pendingEvents.length, "events");
for (const event of pendingEvents) {
try {
await this.networkService.sendEvent(event);
Logger.debug("Event sent from queue:", event.name);
this.eventStorage.removeEvent(event);
this.consecutiveFailures = 0;
}
catch {
this.consecutiveFailures++;
// Increment retry count and drop events that exceeded max retries
event.retryCount = (event.retryCount ?? 0) + 1;
if (event.retryCount >= EventService.MAX_EVENT_RETRIES) {
Logger.warn("Dropping event after max retries:", event.name);
this.eventStorage.removeEvent(event);
}
else if (this.consecutiveFailures === 1) {
// Only log the first failure, not every retry
Logger.warn("Failed to send event, will retry:", event.name);
}
// Stop processing remaining events on failure
if (this.consecutiveFailures >= EventService.MAX_CONSECUTIVE_FAILURES) {
Logger.warn("Queue paused after", EventService.MAX_CONSECUTIVE_FAILURES, "consecutive failures");
return;
}
}
}
}
catch (error) {
Logger.error("Critical error in processQueue:", error);
}
}
/** Reset failure counter (e.g. after network recovery). */
resetFailureCount() {
this.consecutiveFailures = 0;
}
}
EventService.MAX_CONSECUTIVE_FAILURES = 5;
EventService.MAX_EVENT_RETRIES = 3;
/**
* In-memory event storage with queue semantics.
* Mirrors Android InMemoryEventStorage.
*/
class InMemoryEventStorage {
constructor() {
this.events = [];
}
saveEvent(event) {
this.events.push(event);
}
getPendingEvents() {
return [...this.events];
}
removeEvent(event) {
this.events = this.events.filter((e) => e.key !== event.key);
}
clear() {
this.events = [];
}
}
/**
* Thread-safe (single-threaded in JS) shared state container.
* Mirrors Android DataPayload — holds config and collected variables.
*/
class DataPayload {
constructor() {
this.trackerConfig = null;
this.configuration = null;
this.collectedVariables = new Map();
this.userDefinedValues = new Map();
}
// --- TrackerConfig (backend) ---
setTrackerConfig(config) {
this.trackerConfig = config;
}
getTrackerConfig() {
return this.trackerConfig;
}
// --- TrackerConfiguration (SDK init) ---
setConfiguration(config) {
this.configuration = config;
}
getConfiguration() {
return this.configuration;
}
// --- Collected variables ---
setVariable(type, value) {
this.collectedVariables.set(type, value);
}
getVariable(type) {
return this.collectedVariables.get(type);
}
getAllVariables() {
return new Map(this.collectedVariables);
}
getCollectedVariables() {
return new Map(this.collectedVariables);
}
updateVariables(variables) {
for (const [key, value] of variables) {
this.collectedVariables.set(key, value);
}
}
// --- User-defined values ---
setUserDefinedValue(key, value) {
this.userDefinedValues.set(key, value);
}
getUserDefinedValue(key) {
return this.userDefinedValues.get(key);
}
getUserDefinedValues() {
return Object.fromEntries(this.userDefinedValues);
}
// --- Device ID ---
getDeviceId() {
try {
let id = localStorage.getItem("formica_device_id");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("formica_device_id", id);
}
return id;
}
catch {
return `anon_${Date.now()}`;
}
}
// --- Cleanup ---
clear() {
this.trackerConfig = null;
this.collectedVariables.clear();
this.userDefinedValues.clear();
}
}
/**
* DI module that wires up all SDK components.
* Mirrors Android TrackerModule + DaggerTrackerComponent.
*/
class TrackerModule {
constructor(configuration) {
this.configuration = configuration;
this.build();
}
build() {
// 1. Data payload (shared state)
this.payload = new DataPayload();
this.payload.setConfiguration(this.configuration);
// 2. Collectors
this.deviceCollector = new DeviceCollector();
this.networkCollector = new NetworkCollector();
this.securityCollector = new SecurityCollector();
this.behavioralCollector = new BehavioralCollector();
this.screenActivityCollector = new ScreenActivityCollector();
const collectors = [
this.deviceCollector,
this.networkCollector,
this.securityCollector,
this.behavioralCollector,
this.screenActivityCollector,
];
// 3. Network service
this.networkService = new DefaultNetworkService(this.configuration);
// 4. Event storage & service
this.eventStorage = new InMemoryEventStorage();
this.eventService = new EventService(this.eventStorage, this.networkService, this.configuration);
// 5. Event coordinator
this.eventCoordinator = new EventCoordinator(this.eventService, collectors, this.payload, this.screenActivityCollector, this.securityCollector);
// 6. Periodic scheduler (security + behavioral rolling window reset)
this.scheduler = new PeriodicDataScheduler(() => {
this.securityCollector.resetRollingWindow();
this.behavioralCollector.resetRollingWindow();
}, 60000);
// 7. Engine
this.engine = new TrackerEngine(this.eventService, this.eventCoordinator, this.networkService, this.payload, this.scheduler, this.deviceCollector, this.networkCollector, this.securityCollector, this.behavioralCollector, this.screenActivityCollector);
}
getTrackerEngine() {
return this.engine;
}
}
/**
* Main entry point for the Tracker Web SDK.
* Mirrors Android TrackerSDK — Builder pattern, instance-based API.
*
* @example
* ```ts
* const tracker = new TrackerSDK.Builder()
* .setConfiguration({
* tenant: 'my-tenant',
* apiUrl: 'https://api.example.com',
* apiKey: 'my-api-key',
* })
* .build();
*
* tracker.initialize({
* onComplete(success, message) {
* if (success) console.log('SDK initialized');
* },
* });
* ```
*/
class TrackerSDK {
constructor(configuration) {
this.isInitialized = false;
Logger.setEnabled(configuration.debug ?? false);
const module = new TrackerModule(configuration);
this.engine = module.getTrackerEngine();
}
/**
* Initializes the Tracker SDK. Fetches configuration from backend,
* sets up collectors, and starts event listeners.
*/
async initialize(callback) {
try {
if (this.isInitialized) {
callback.onComplete(true, "Tracker SDK already initialized");
return;
}
await this.engine.initialize({
onComplete: (success, message) => {
if (success) {
this.isInitialized = true;
}
callback.onComplete(success, message);
},
});
}
catch (error) {
try {
const message = error instanceof Error ? error.message : String(error);
callback.onComplete(false, `Initialization failed: ${message}`);
}
catch {
// Callback itself threw, nothing we can do
}
}
}
/** Checks if the SDK is initialized. */
getIsInitialized() {
return this.isInitialized;
}
/**
* Triggers a custom event asynchronously. The event will be queued
* and sent when network is available.
*/
triggerCustomEvent(eventName, args = {}) {
if (!this.isInitialized)
return;
try {
this.engine.triggerCustomEvent(eventName, args);
}
catch {
// Never crash on custom events
}
}
/**
* Triggers a custom event synchronously with a callback.
* The callback will be invoked with the server response.
*/
async triggerCustomEventSync(eventName, args, callback) {
if (!this.isInitialized) {
callback.onError(new Error("Tracker SDK not initialized"));
return;
}
try {
await this.engine.triggerCustomEventSync(eventName, args, callback);
}
catch (error) {
try {
callback.onError(error instanceof Error ? error : new Error(String(error)));
}
catch {
// Callback threw
}
}
}
/** Sets a user-defined value that will be included in all events. */
setUserDefinedValue(key, value) {
if (!this.isInitialized)
return;
this.engine.setUserDefinedValue(key, value);
}
/** Gets all user-defined values. */
getUserDefinedValues() {
if (!this.isInitialized)
return null;
return this.engine.getUserDefinedValues();
}
/** Gets all currently collected variables from all collectors. */
getAllVariables() {
if (!this.isInitialized)
return null;
return this.engine.getAllVariables();
}
/** Gets the current tracker configuration from backend. */
getConfiguration() {
if (!this.isInitialized)
return null;
return this.engine.getConfiguration();
}
/** Triggers a native event programmatically (for testing/debugging). */
triggerNativeEvent(eventType) {
if (!this.isInitialized)
return;
this.engine.triggerNativeEvent(eventType);
}
/** Sets a listener for native tracker events. */
setNativeEventListener(listener) {
if (!this.isInitialized)
return;
this.engine.setNativeEventListener(listener);
}
/**
* Terminates the SDK and releases all resources.
* After calling this, you must re-create and re-initialize to use it again.
*/
terminate() {
if (!this.isInitialized)
return;
try {
this.engine.terminate();
}
catch {
// Log but continue
}
finally {
this.isInitialized = false;
}
}
}
/** Builder class for creating TrackerSDK instances. */
TrackerSDK.Builder = class TrackerSDKBuilder {
constructor() {
this.configuration = null;
}
setConfiguration(configuration) {
this.configuration = configuration;
return this;
}
build() {
if (!this.configuration) {
throw new Error("Configuration must be set before building TrackerSDK");
}
return new TrackerSDK(this.configuration);
}
};
exports.TrackerSDK = TrackerSDK;
//# sourceMappingURL=bundle.js.map