@levo-so/insights 0.1.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +305 -0
- package/dist/index.js +941 -0
- package/package.json +46 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { ILevoClient } from '@levo-so/core';
|
|
2
|
+
import { AnalyticsInstance } from 'analytics';
|
|
3
|
+
|
|
4
|
+
declare const AnalyticsEventsList: readonly ["bevy.collection.filled", "bevy.collection.submitted", "bevy.collection.view", "block.view", "blog.post.view", "event.booking.confirmed", "event.booking.initiated", "event.booking.pending", "event.coupon.applied", "event.event.view", "form.change", "form.submit", "membership.account.signin", "membership.account.signout", "membership.account.signup", "button.click", "page.bounce", "page.click", "page.copy", "page.impression", "page.scroll", "page.view", "page.selection", "user.active", "user.idle"];
|
|
5
|
+
type AnalyticsEventType = (typeof AnalyticsEventsList)[number];
|
|
6
|
+
declare const AnalyticsEvents: {
|
|
7
|
+
"bevy.collection.filled": "bevy.collection.filled";
|
|
8
|
+
"bevy.collection.submitted": "bevy.collection.submitted";
|
|
9
|
+
"bevy.collection.view": "bevy.collection.view";
|
|
10
|
+
"block.view": "block.view";
|
|
11
|
+
"blog.post.view": "blog.post.view";
|
|
12
|
+
"event.booking.confirmed": "event.booking.confirmed";
|
|
13
|
+
"event.booking.initiated": "event.booking.initiated";
|
|
14
|
+
"event.booking.pending": "event.booking.pending";
|
|
15
|
+
"event.coupon.applied": "event.coupon.applied";
|
|
16
|
+
"event.event.view": "event.event.view";
|
|
17
|
+
"form.change": "form.change";
|
|
18
|
+
"form.submit": "form.submit";
|
|
19
|
+
"membership.account.signin": "membership.account.signin";
|
|
20
|
+
"membership.account.signout": "membership.account.signout";
|
|
21
|
+
"membership.account.signup": "membership.account.signup";
|
|
22
|
+
"button.click": "button.click";
|
|
23
|
+
"page.bounce": "page.bounce";
|
|
24
|
+
"page.click": "page.click";
|
|
25
|
+
"page.copy": "page.copy";
|
|
26
|
+
"page.impression": "page.impression";
|
|
27
|
+
"page.scroll": "page.scroll";
|
|
28
|
+
"page.view": "page.view";
|
|
29
|
+
"page.selection": "page.selection";
|
|
30
|
+
"user.active": "user.active";
|
|
31
|
+
"user.idle": "user.idle";
|
|
32
|
+
};
|
|
33
|
+
declare namespace ILevoAudience {
|
|
34
|
+
interface Traits {
|
|
35
|
+
private_mode: boolean;
|
|
36
|
+
dark_mode: boolean;
|
|
37
|
+
locale: string;
|
|
38
|
+
timezone: string;
|
|
39
|
+
referrer: {
|
|
40
|
+
type: string;
|
|
41
|
+
referrer: Record<string, string>;
|
|
42
|
+
data: Record<string, string>;
|
|
43
|
+
};
|
|
44
|
+
properties: Record<string, any>;
|
|
45
|
+
withLock?: boolean;
|
|
46
|
+
}
|
|
47
|
+
type BaseProperties = Traits & {
|
|
48
|
+
resource: string;
|
|
49
|
+
identifier: string;
|
|
50
|
+
};
|
|
51
|
+
interface BlockProperties {
|
|
52
|
+
id: string;
|
|
53
|
+
}
|
|
54
|
+
interface ActivityProperties {
|
|
55
|
+
seconds_idle?: number;
|
|
56
|
+
seconds_active?: number;
|
|
57
|
+
}
|
|
58
|
+
interface ScrollProperties {
|
|
59
|
+
percent: number;
|
|
60
|
+
}
|
|
61
|
+
type Properties = Partial<BaseProperties & BlockProperties & ActivityProperties & ScrollProperties> & Partial<Omit<BaseInput, "workspace_id" | "tab_count" | "tab_id">> & Record<string, any>;
|
|
62
|
+
type EventProperties = {
|
|
63
|
+
properties: Properties;
|
|
64
|
+
traits: Traits;
|
|
65
|
+
event: AnalyticsEventType;
|
|
66
|
+
type: "track" | "page" | "identify";
|
|
67
|
+
userId: string | null;
|
|
68
|
+
};
|
|
69
|
+
type BaseInput = {
|
|
70
|
+
workspace_id: string;
|
|
71
|
+
site_id?: string;
|
|
72
|
+
hostname: string;
|
|
73
|
+
pathname: string;
|
|
74
|
+
page_title: string;
|
|
75
|
+
url: string;
|
|
76
|
+
created_at: string;
|
|
77
|
+
tab_id: string;
|
|
78
|
+
tab_count: number;
|
|
79
|
+
};
|
|
80
|
+
type WelcomeInput = Traits & BaseInput;
|
|
81
|
+
type WelcomeResponse = {
|
|
82
|
+
session: string;
|
|
83
|
+
device: string;
|
|
84
|
+
};
|
|
85
|
+
type CollectInput = BaseInput & {
|
|
86
|
+
session_id?: string;
|
|
87
|
+
device_id?: string;
|
|
88
|
+
event: AnalyticsEventType;
|
|
89
|
+
version?: number;
|
|
90
|
+
properties: Properties;
|
|
91
|
+
resource: string;
|
|
92
|
+
identifier: string | undefined;
|
|
93
|
+
};
|
|
94
|
+
type ActivityStatus = {
|
|
95
|
+
isIdle: boolean;
|
|
96
|
+
isDisabled: boolean;
|
|
97
|
+
active: number;
|
|
98
|
+
idle: number;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface IInsightOptions {
|
|
103
|
+
/** Site ID for analytics tracking */
|
|
104
|
+
site?: string | null;
|
|
105
|
+
insightsUrl?: string;
|
|
106
|
+
/** Direct insights API URL for third-party mode (when reverse proxy unavailable) */
|
|
107
|
+
directInsightsUrl?: string;
|
|
108
|
+
/** Insights mode: 'auto' (default) detects via ping, 'proxy' forces reverse proxy, 'direct' forces direct API */
|
|
109
|
+
insightsMode?: "auto" | "proxy" | "direct";
|
|
110
|
+
/** Timeout for proxy ping detection in ms (default: 3000) */
|
|
111
|
+
pingTimeout?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @fileoverview Levo Audience Analytics Module
|
|
116
|
+
*
|
|
117
|
+
* This module provides client-side analytics tracking for Levo-powered websites.
|
|
118
|
+
* It handles event collection, batching, cross-tab synchronization, and reliable
|
|
119
|
+
* delivery of analytics data to the Levo Insights API.
|
|
120
|
+
*
|
|
121
|
+
* ## Architecture Overview
|
|
122
|
+
*
|
|
123
|
+
* ```
|
|
124
|
+
* ┌────────────────────────────────────────────────────────────────────────┐
|
|
125
|
+
* │ Browser Tab │
|
|
126
|
+
* │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
|
|
127
|
+
* │ │ track() │───▶│ EventQueue │───▶│ /v1/insights/event/bulk │ │
|
|
128
|
+
* │ │ page() │ │ (batching) │ │ (sendBeacon / fetch) │ │
|
|
129
|
+
* │ │ identify() │ └──────────────┘ └──────────────────────────┘ │
|
|
130
|
+
* │ └──────────────┘ │ │
|
|
131
|
+
* │ │ │ flush on: │
|
|
132
|
+
* │ │ │ - 50 events (BATCH_SIZE) │
|
|
133
|
+
* │ │ │ - 5s interval (BATCH_INTERVAL_MS) │
|
|
134
|
+
* │ │ │ - page exit (visibilitychange) │
|
|
135
|
+
* │ │ │ - beforeunload / pagehide │
|
|
136
|
+
* │ ▼ │ │
|
|
137
|
+
* │ ┌──────────────┐ │ │
|
|
138
|
+
* │ │ Rate Limiter │───────────┘ │
|
|
139
|
+
* │ │ (500/page) │ │
|
|
140
|
+
* │ └──────────────┘ │
|
|
141
|
+
* └────────────────────────────────────────────────────────────────────────┘
|
|
142
|
+
* │
|
|
143
|
+
* │ BroadcastChannel
|
|
144
|
+
* │ (SESSION_SYNC, SESSION_REQUEST)
|
|
145
|
+
* ▼
|
|
146
|
+
* ┌────────────────────────────────────────────────────────────────────────┐
|
|
147
|
+
* │ Other Browser Tabs │
|
|
148
|
+
* │ - Receive session/device IDs from first tab │
|
|
149
|
+
* │ - Skip /welcome API call if session already established │
|
|
150
|
+
* └────────────────────────────────────────────────────────────────────────┘
|
|
151
|
+
* ```
|
|
152
|
+
*
|
|
153
|
+
* ## Key Features
|
|
154
|
+
*
|
|
155
|
+
* ### 1. Event Batching
|
|
156
|
+
* Instead of sending each event immediately (which caused 100+ requests per page),
|
|
157
|
+
* events are queued and sent in batches. This reduces:
|
|
158
|
+
* - Network requests by 50-100x
|
|
159
|
+
* - Server load
|
|
160
|
+
* - Bandwidth usage
|
|
161
|
+
*
|
|
162
|
+
* ### 2. Cross-Tab Session Synchronization
|
|
163
|
+
* Uses BroadcastChannel API to share session/device IDs across tabs:
|
|
164
|
+
* - First tab to complete /welcome broadcasts session to others
|
|
165
|
+
* - Other tabs skip /welcome API call, reducing duplicate sessions
|
|
166
|
+
* - Solves "Open in New Tab" race condition where multiple tabs would
|
|
167
|
+
* each create separate sessions
|
|
168
|
+
*
|
|
169
|
+
* ### 3. Reliable Page Exit Handling
|
|
170
|
+
* Uses multiple mechanisms to ensure events are sent before page unload:
|
|
171
|
+
* - visibilitychange (most reliable, fires when tab hidden)
|
|
172
|
+
* - beforeunload (backup for navigation)
|
|
173
|
+
* - pagehide (backup for bfcache scenarios)
|
|
174
|
+
* - sendBeacon API (survives page unload)
|
|
175
|
+
*
|
|
176
|
+
* ### 4. Rate Limiting
|
|
177
|
+
* Prevents runaway loops or malicious spam:
|
|
178
|
+
* - Max 500 events per page
|
|
179
|
+
* - Counter resets on page navigation
|
|
180
|
+
*
|
|
181
|
+
* ## Session Flow
|
|
182
|
+
*
|
|
183
|
+
* ```
|
|
184
|
+
* Page Load
|
|
185
|
+
* │
|
|
186
|
+
* ▼
|
|
187
|
+
* initiate() called
|
|
188
|
+
* │
|
|
189
|
+
* ├── Initialize BroadcastChannel
|
|
190
|
+
* │
|
|
191
|
+
* ├── Request session from other tabs (SESSION_REQUEST)
|
|
192
|
+
* │
|
|
193
|
+
* ├── Wait 50ms for response
|
|
194
|
+
* │ │
|
|
195
|
+
* │ ├── If SESSION_SYNC received: Use shared session, skip /welcome
|
|
196
|
+
* │ │
|
|
197
|
+
* │ └── If no response: Call /welcome API
|
|
198
|
+
* │ │
|
|
199
|
+
* │ └── On success: Broadcast SESSION_SYNC to other tabs
|
|
200
|
+
* │
|
|
201
|
+
* └── Start tracking events
|
|
202
|
+
* ```
|
|
203
|
+
*
|
|
204
|
+
* ## API Endpoints Used
|
|
205
|
+
*
|
|
206
|
+
* - `POST /v1/insights/event/welcome` - Initial session establishment
|
|
207
|
+
* - Called once per session (first tab only, thanks to BroadcastChannel)
|
|
208
|
+
* - Returns session_id and device_id from HttpOnly cookies
|
|
209
|
+
*
|
|
210
|
+
* - `POST /v1/insights/event/bulk` - Batch event submission
|
|
211
|
+
* - Receives array of CollectInput events
|
|
212
|
+
* - Used for all event tracking (replaces individual /collect calls)
|
|
213
|
+
*
|
|
214
|
+
* ## Configuration Constants
|
|
215
|
+
*
|
|
216
|
+
* - BATCH_SIZE (50): Flush queue when this many events accumulated
|
|
217
|
+
* - BATCH_INTERVAL_MS (5000): Flush queue every 5 seconds regardless of size
|
|
218
|
+
* - MAX_EVENTS_PER_PAGE (500): Rate limit per page to prevent abuse
|
|
219
|
+
* - BROADCAST_CHANNEL_NAME ("levo_analytics"): Channel for cross-tab sync
|
|
220
|
+
*
|
|
221
|
+
* ## Browser Compatibility
|
|
222
|
+
*
|
|
223
|
+
* - BroadcastChannel: Modern browsers (Chrome 54+, Firefox 38+, Safari 15.4+)
|
|
224
|
+
* - Gracefully degrades: If unavailable, each tab calls /welcome independently
|
|
225
|
+
*
|
|
226
|
+
* - sendBeacon: Universal support (Chrome 39+, Firefox 31+, Safari 11.1+)
|
|
227
|
+
* - Fallback: fetch() with keepalive option
|
|
228
|
+
*
|
|
229
|
+
* @module @levo-so/core/control/audience
|
|
230
|
+
*/
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Creates the Levo Audience analytics module.
|
|
234
|
+
*
|
|
235
|
+
* This factory function creates a new audience module instance tied to
|
|
236
|
+
* a specific Levo control instance and HTTP client. The module manages
|
|
237
|
+
* all analytics tracking for the page.
|
|
238
|
+
*
|
|
239
|
+
* @param core - The Levo control instance containing workspace config
|
|
240
|
+
* @param httpClient - HTTP client for making API requests
|
|
241
|
+
* @returns The audience module with tracking methods
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```typescript
|
|
245
|
+
* const audience = createLevoAudienceModule(levoControl, httpClient);
|
|
246
|
+
*
|
|
247
|
+
* // Initialize on page load
|
|
248
|
+
* await audience.initiate({ id: 'page-123' });
|
|
249
|
+
*
|
|
250
|
+
* // Track custom events
|
|
251
|
+
* audience.track('button.click', { buttonId: 'cta-signup' });
|
|
252
|
+
*
|
|
253
|
+
* // Cleanup on unmount (optional, for SPAs)
|
|
254
|
+
* audience.destroy();
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
declare const createLevoInsights: (client: ILevoClient, options?: IInsightOptions) => {
|
|
258
|
+
readonly insightsUrl: string;
|
|
259
|
+
/** Get the underlying analytics instance (read-only) */
|
|
260
|
+
readonly instance: AnalyticsInstance | null;
|
|
261
|
+
/** Check if session has been established (read-only) */
|
|
262
|
+
readonly is_identified: boolean;
|
|
263
|
+
/** Storage keys used by the module */
|
|
264
|
+
storage_keys: {
|
|
265
|
+
/** Legacy: Lock key for identify operation (no longer used internally) */
|
|
266
|
+
identify: string;
|
|
267
|
+
/** Key for tracking open tab count */
|
|
268
|
+
open_tabs: string;
|
|
269
|
+
/** Key for current tab ID (sessionStorage) */
|
|
270
|
+
current_tab: string;
|
|
271
|
+
/** Key for all tabs registry */
|
|
272
|
+
all_tabs: string;
|
|
273
|
+
/** Device JWT for direct mode (1yr expiry, rotated on /welcome, analytics-only scope) */
|
|
274
|
+
device_token: string;
|
|
275
|
+
/** Session JWT for direct mode (1yr expiry, rotated on /welcome, analytics-only scope) */
|
|
276
|
+
session_token: string;
|
|
277
|
+
/** Cached insights mode ('proxy' | 'direct') */
|
|
278
|
+
mode: string;
|
|
279
|
+
};
|
|
280
|
+
/** Initialize the module for a page */
|
|
281
|
+
initiate: (properties: ILevoAudience.Properties) => Promise<any>;
|
|
282
|
+
/** Identify/establish session */
|
|
283
|
+
identify: (options?: {
|
|
284
|
+
withLock?: boolean;
|
|
285
|
+
}) => Promise<void>;
|
|
286
|
+
/** Track a custom event */
|
|
287
|
+
track: <T extends ILevoAudience.Properties>(event: AnalyticsEventType, properties: T & ILevoAudience.Properties) => Promise<any>;
|
|
288
|
+
/** Track a bounce event */
|
|
289
|
+
bounce: (properties: ILevoAudience.Properties) => Promise<void>;
|
|
290
|
+
/** Cleanup the module */
|
|
291
|
+
destroy: () => void;
|
|
292
|
+
};
|
|
293
|
+
/**
|
|
294
|
+
* Type representing the audience module instance.
|
|
295
|
+
* Use this when you need to type a variable holding the module.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```typescript
|
|
299
|
+
* let audience: ILevoAudienceModule;
|
|
300
|
+
* audience = createLevoAudienceModule(core, httpClient);
|
|
301
|
+
* ```
|
|
302
|
+
*/
|
|
303
|
+
type ILevoInsights = ReturnType<typeof createLevoInsights>;
|
|
304
|
+
|
|
305
|
+
export { type AnalyticsEventType, AnalyticsEvents, AnalyticsEventsList, type IInsightOptions, ILevoAudience, type ILevoInsights, createLevoInsights };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
import { onUserActivity } from '@analytics/activity-utils';
|
|
2
|
+
import { getVisitorSource } from '@analytics/visitor-source';
|
|
3
|
+
import { listToRecord, getLevoError } from '@levo-so/core';
|
|
4
|
+
import { Analytics } from 'analytics';
|
|
5
|
+
import getUserLocale from 'get-user-locale';
|
|
6
|
+
|
|
7
|
+
// src/index.ts
|
|
8
|
+
|
|
9
|
+
// src/utils/isIncognito.ts
|
|
10
|
+
var isIncognito = () => new Promise((resolve, reject) => {
|
|
11
|
+
let browserName = "Unknown";
|
|
12
|
+
function __callback(isPrivate) {
|
|
13
|
+
resolve({
|
|
14
|
+
isPrivate,
|
|
15
|
+
browserName
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function identifyChromium() {
|
|
19
|
+
const ua = navigator.userAgent;
|
|
20
|
+
if (ua.match(/Chrome/)) {
|
|
21
|
+
if (navigator.brave !== void 0) {
|
|
22
|
+
return "Brave";
|
|
23
|
+
}
|
|
24
|
+
if (ua.match(/Edg/)) {
|
|
25
|
+
return "Edge";
|
|
26
|
+
}
|
|
27
|
+
if (ua.match(/OPR/)) {
|
|
28
|
+
return "Opera";
|
|
29
|
+
}
|
|
30
|
+
return "Chrome";
|
|
31
|
+
}
|
|
32
|
+
return "Chromium";
|
|
33
|
+
}
|
|
34
|
+
function assertEvalToString(value) {
|
|
35
|
+
return value === eval.toString().length;
|
|
36
|
+
}
|
|
37
|
+
function isSafari() {
|
|
38
|
+
const v = navigator.vendor;
|
|
39
|
+
return v !== void 0 && v.indexOf("Apple") === 0 && assertEvalToString(37);
|
|
40
|
+
}
|
|
41
|
+
function isChrome() {
|
|
42
|
+
const v = navigator.vendor;
|
|
43
|
+
return v !== void 0 && v.indexOf("Google") === 0 && assertEvalToString(33);
|
|
44
|
+
}
|
|
45
|
+
function isFirefox() {
|
|
46
|
+
return document.documentElement !== void 0 && document.documentElement.style.MozAppearance !== void 0 && assertEvalToString(37);
|
|
47
|
+
}
|
|
48
|
+
function isMSIE() {
|
|
49
|
+
return navigator.msSaveBlob !== void 0 && assertEvalToString(39);
|
|
50
|
+
}
|
|
51
|
+
function newSafariTest() {
|
|
52
|
+
const tmp_name = String(Math.random());
|
|
53
|
+
try {
|
|
54
|
+
const db = window.indexedDB.open(tmp_name, 1);
|
|
55
|
+
db.onupgradeneeded = (i) => {
|
|
56
|
+
const res = i.target?.result;
|
|
57
|
+
try {
|
|
58
|
+
res.createObjectStore("test", {
|
|
59
|
+
autoIncrement: true
|
|
60
|
+
}).put(new Blob());
|
|
61
|
+
__callback(false);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
let message = e;
|
|
64
|
+
if (e instanceof Error) {
|
|
65
|
+
message = e.message ?? e;
|
|
66
|
+
}
|
|
67
|
+
if (typeof message !== "string") {
|
|
68
|
+
return __callback(false);
|
|
69
|
+
}
|
|
70
|
+
const matchesExpectedError = /BlobURLs are not yet supported/.test(message);
|
|
71
|
+
return __callback(matchesExpectedError);
|
|
72
|
+
} finally {
|
|
73
|
+
res.close();
|
|
74
|
+
window.indexedDB.deleteDatabase(tmp_name);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return __callback(false);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function oldSafariTest() {
|
|
82
|
+
const openDB = window.openDatabase;
|
|
83
|
+
const storage = window.localStorage;
|
|
84
|
+
try {
|
|
85
|
+
openDB(null, null, null, null);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return __callback(true);
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
storage.setItem("test", "1");
|
|
91
|
+
storage.removeItem("test");
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return __callback(true);
|
|
94
|
+
}
|
|
95
|
+
return __callback(false);
|
|
96
|
+
}
|
|
97
|
+
function safariPrivateTest() {
|
|
98
|
+
if (navigator.maxTouchPoints !== void 0) {
|
|
99
|
+
newSafariTest();
|
|
100
|
+
} else {
|
|
101
|
+
oldSafariTest();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function getQuotaLimit() {
|
|
105
|
+
const w = window;
|
|
106
|
+
if (w.performance !== void 0 && w.performance.memory !== void 0 && w.performance.memory.jsHeapSizeLimit !== void 0) {
|
|
107
|
+
return performance.memory.jsHeapSizeLimit;
|
|
108
|
+
}
|
|
109
|
+
return 1073741824;
|
|
110
|
+
}
|
|
111
|
+
function storageQuotaChromePrivateTest() {
|
|
112
|
+
navigator.webkitTemporaryStorage.queryUsageAndQuota(
|
|
113
|
+
(_, quota) => {
|
|
114
|
+
const quotaInMib = Math.round(quota / (1024 * 1024));
|
|
115
|
+
const quotaLimitInMib = Math.round(getQuotaLimit() / (1024 * 1024)) * 2;
|
|
116
|
+
__callback(quotaInMib < quotaLimitInMib);
|
|
117
|
+
},
|
|
118
|
+
(e) => {
|
|
119
|
+
reject(new Error(`detectIncognito somehow failed to query storage quota: ${e.message}`));
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
function oldChromePrivateTest() {
|
|
124
|
+
const fs = window.webkitRequestFileSystem;
|
|
125
|
+
const success = () => {
|
|
126
|
+
__callback(false);
|
|
127
|
+
};
|
|
128
|
+
const error = () => {
|
|
129
|
+
__callback(true);
|
|
130
|
+
};
|
|
131
|
+
fs(0, 1, success, error);
|
|
132
|
+
}
|
|
133
|
+
function chromePrivateTest() {
|
|
134
|
+
if (self.Promise !== void 0 && self.Promise.allSettled !== void 0) {
|
|
135
|
+
storageQuotaChromePrivateTest();
|
|
136
|
+
} else {
|
|
137
|
+
oldChromePrivateTest();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function firefoxPrivateTest() {
|
|
141
|
+
__callback(navigator.serviceWorker === void 0);
|
|
142
|
+
}
|
|
143
|
+
function msiePrivateTest() {
|
|
144
|
+
__callback(window.indexedDB === void 0);
|
|
145
|
+
}
|
|
146
|
+
function main() {
|
|
147
|
+
if (isSafari()) {
|
|
148
|
+
browserName = "Safari";
|
|
149
|
+
safariPrivateTest();
|
|
150
|
+
} else if (isChrome()) {
|
|
151
|
+
browserName = identifyChromium();
|
|
152
|
+
chromePrivateTest();
|
|
153
|
+
} else if (isFirefox()) {
|
|
154
|
+
browserName = "Firefox";
|
|
155
|
+
firefoxPrivateTest();
|
|
156
|
+
} else if (isMSIE()) {
|
|
157
|
+
browserName = "Internet Explorer";
|
|
158
|
+
msiePrivateTest();
|
|
159
|
+
} else {
|
|
160
|
+
reject(new Error("detectIncognito cannot determine the browser"));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
main();
|
|
164
|
+
});
|
|
165
|
+
var isIncognito_default = isIncognito;
|
|
166
|
+
|
|
167
|
+
// src/utils/getUserProperties.ts
|
|
168
|
+
var getGPUInfo = () => {
|
|
169
|
+
const canvas = document.createElement("canvas");
|
|
170
|
+
const gl = canvas?.getContext("webgl") || canvas?.getContext("experimental-webgl");
|
|
171
|
+
if (!gl) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const debugInfo = gl?.getExtension("WEBGL_debug_renderer_info");
|
|
175
|
+
return debugInfo ? gl?.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : null;
|
|
176
|
+
};
|
|
177
|
+
var getUserProperties = async () => {
|
|
178
|
+
let locale = "";
|
|
179
|
+
let timezone = "";
|
|
180
|
+
let dark_mode = false;
|
|
181
|
+
let private_mode = false;
|
|
182
|
+
const properties = {};
|
|
183
|
+
if (!window) {
|
|
184
|
+
return {
|
|
185
|
+
locale,
|
|
186
|
+
timezone,
|
|
187
|
+
dark_mode,
|
|
188
|
+
private_mode,
|
|
189
|
+
properties
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
({ isPrivate: private_mode } = await isIncognito_default());
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.error(e);
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
dark_mode = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
|
199
|
+
} catch (e) {
|
|
200
|
+
console.error(e);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error(e);
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
locale = getUserLocale();
|
|
209
|
+
} catch (e) {
|
|
210
|
+
console.error(e);
|
|
211
|
+
}
|
|
212
|
+
if (navigator && "connection" in navigator) {
|
|
213
|
+
const connection = navigator.connection || navigator?.mozConnection || navigator.webkitConnection;
|
|
214
|
+
properties.network_type = connection.effectiveType;
|
|
215
|
+
}
|
|
216
|
+
properties.width = window?.innerWidth;
|
|
217
|
+
properties.height = window?.innerHeight;
|
|
218
|
+
properties.pixel_ratio = window?.devicePixelRatio;
|
|
219
|
+
if (screen) {
|
|
220
|
+
properties.screen_width = screen?.width;
|
|
221
|
+
properties.screen_height = screen?.height;
|
|
222
|
+
properties.color_depth = screen?.colorDepth;
|
|
223
|
+
properties.orientation = screen?.orientation?.type;
|
|
224
|
+
}
|
|
225
|
+
if (navigator && "getBattery" in navigator) {
|
|
226
|
+
const battery = await navigator?.getBattery();
|
|
227
|
+
if (battery) {
|
|
228
|
+
properties.battery_level = Number((battery?.level * 100).toFixed(0));
|
|
229
|
+
properties.battery_charging = battery?.charging;
|
|
230
|
+
properties.battery_discharging_time = battery?.dischargingTime;
|
|
231
|
+
properties.battery_full_charge_capacity = battery?.fullChargeCapacity;
|
|
232
|
+
properties.battery_charging_time = battery?.chargingTime;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (navigator && "deviceMemory" in navigator) {
|
|
236
|
+
properties.device_memory = navigator?.deviceMemory;
|
|
237
|
+
}
|
|
238
|
+
const supportsTouch = "ontouchstart" in window || typeof navigator?.maxTouchPoints !== "undefined" && navigator?.maxTouchPoints > 0;
|
|
239
|
+
properties.touch_supported = supportsTouch;
|
|
240
|
+
if ("hardwareConcurrency" in navigator) {
|
|
241
|
+
properties.hardware_concurrency = navigator?.hardwareConcurrency;
|
|
242
|
+
}
|
|
243
|
+
if (navigator && "mediaDevices" in navigator) {
|
|
244
|
+
const mediaDevices = navigator?.mediaDevices;
|
|
245
|
+
const devices = await mediaDevices?.enumerateDevices();
|
|
246
|
+
const hasCamera = devices.some((device) => device?.kind === "videoinput");
|
|
247
|
+
properties.camera_supported = hasCamera;
|
|
248
|
+
const hasMicrophone = devices.some((device) => device?.kind === "audioinput");
|
|
249
|
+
properties.microphone_supported = hasMicrophone;
|
|
250
|
+
}
|
|
251
|
+
properties.gpu = getGPUInfo();
|
|
252
|
+
return {
|
|
253
|
+
locale,
|
|
254
|
+
timezone,
|
|
255
|
+
dark_mode,
|
|
256
|
+
private_mode,
|
|
257
|
+
properties
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
var AnalyticsEventsList = [
|
|
261
|
+
// Bevy Events
|
|
262
|
+
"bevy.collection.filled",
|
|
263
|
+
"bevy.collection.submitted",
|
|
264
|
+
"bevy.collection.view",
|
|
265
|
+
// Block Events
|
|
266
|
+
"block.view",
|
|
267
|
+
// Blog Events
|
|
268
|
+
"blog.post.view",
|
|
269
|
+
// Event Events
|
|
270
|
+
"event.booking.confirmed",
|
|
271
|
+
"event.booking.initiated",
|
|
272
|
+
"event.booking.pending",
|
|
273
|
+
"event.coupon.applied",
|
|
274
|
+
"event.event.view",
|
|
275
|
+
// Form Events
|
|
276
|
+
"form.change",
|
|
277
|
+
"form.submit",
|
|
278
|
+
// Membership Events
|
|
279
|
+
"membership.account.signin",
|
|
280
|
+
"membership.account.signout",
|
|
281
|
+
"membership.account.signup",
|
|
282
|
+
// UI Events
|
|
283
|
+
"button.click",
|
|
284
|
+
"page.bounce",
|
|
285
|
+
"page.click",
|
|
286
|
+
"page.copy",
|
|
287
|
+
"page.impression",
|
|
288
|
+
"page.scroll",
|
|
289
|
+
"page.view",
|
|
290
|
+
"page.selection",
|
|
291
|
+
// User Activity Events
|
|
292
|
+
"user.active",
|
|
293
|
+
"user.idle"
|
|
294
|
+
];
|
|
295
|
+
var AnalyticsEvents = listToRecord(AnalyticsEventsList);
|
|
296
|
+
|
|
297
|
+
// src/index.ts
|
|
298
|
+
var BATCH_SIZE = 50;
|
|
299
|
+
var BATCH_INTERVAL_MS = 5e3;
|
|
300
|
+
var MAX_EVENTS_PER_PAGE = 500;
|
|
301
|
+
var BROADCAST_CHANNEL_NAME = "levo_analytics";
|
|
302
|
+
var createLevoInsights = (client, options = {}) => {
|
|
303
|
+
const { workspace, fetch: levoFetch, NODE_ENV } = client;
|
|
304
|
+
const {
|
|
305
|
+
insightsMode = "auto",
|
|
306
|
+
insightsUrl = "",
|
|
307
|
+
directInsightsUrl = "",
|
|
308
|
+
pingTimeout: providedPingTimeout = 3e3,
|
|
309
|
+
site
|
|
310
|
+
} = options;
|
|
311
|
+
const pingTimeout = Number.isFinite(providedPingTimeout) && providedPingTimeout >= 0 ? providedPingTimeout : 3e3;
|
|
312
|
+
const _IDLE_THROTTLE = 1 * 1e3;
|
|
313
|
+
const _IDLE_TIMEOUT = 60 * 1e3;
|
|
314
|
+
let instance = null;
|
|
315
|
+
let idle_listener = null;
|
|
316
|
+
const storage_keys = {
|
|
317
|
+
/** Legacy: Lock key for identify operation (no longer used internally) */
|
|
318
|
+
identify: "lock:lv_aud_identify",
|
|
319
|
+
/** Key for tracking open tab count */
|
|
320
|
+
open_tabs: "lv_insights_ot",
|
|
321
|
+
/** Key for current tab ID (sessionStorage) */
|
|
322
|
+
current_tab: "lv_insights_ct",
|
|
323
|
+
/** Key for all tabs registry */
|
|
324
|
+
all_tabs: "lv_insights_at",
|
|
325
|
+
/** Device JWT for direct mode (1yr expiry, rotated on /welcome, analytics-only scope) */
|
|
326
|
+
device_token: "lv_aud_device",
|
|
327
|
+
/** Session JWT for direct mode (1yr expiry, rotated on /welcome, analytics-only scope) */
|
|
328
|
+
session_token: "lv_aud_session",
|
|
329
|
+
/** Cached insights mode ('proxy' | 'direct') */
|
|
330
|
+
mode: "lv_aud_mode"
|
|
331
|
+
};
|
|
332
|
+
let _insightsMode = "proxy";
|
|
333
|
+
let _insightsBaseUrl = "";
|
|
334
|
+
let _localStorageAvailable = true;
|
|
335
|
+
const _memoryStorage = /* @__PURE__ */ new Map();
|
|
336
|
+
const checkLocalStorageAvailable = () => {
|
|
337
|
+
if (typeof window === "undefined" || !("localStorage" in window)) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const testKey = "__levo_test__";
|
|
342
|
+
window.localStorage.setItem(testKey, "test");
|
|
343
|
+
window.localStorage.removeItem(testKey);
|
|
344
|
+
return true;
|
|
345
|
+
} catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const getFromStorage = (key) => {
|
|
350
|
+
if (typeof window === "undefined") {
|
|
351
|
+
return _memoryStorage.get(key) || null;
|
|
352
|
+
}
|
|
353
|
+
if (_localStorageAvailable) {
|
|
354
|
+
try {
|
|
355
|
+
return window.localStorage.getItem(key);
|
|
356
|
+
} catch {
|
|
357
|
+
return _memoryStorage.get(key) || null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return _memoryStorage.get(key) || null;
|
|
361
|
+
};
|
|
362
|
+
const setInStorage = (key, value) => {
|
|
363
|
+
if (typeof window === "undefined") {
|
|
364
|
+
_memoryStorage.set(key, value);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (_localStorageAvailable) {
|
|
368
|
+
try {
|
|
369
|
+
window.localStorage.setItem(key, value);
|
|
370
|
+
return;
|
|
371
|
+
} catch {
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
_memoryStorage.set(key, value);
|
|
375
|
+
};
|
|
376
|
+
const removeFromStorage = (key) => {
|
|
377
|
+
_memoryStorage.delete(key);
|
|
378
|
+
if (typeof window === "undefined" || !_localStorageAvailable) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
window.localStorage.removeItem(key);
|
|
383
|
+
} catch {
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
const getAuthHeaders = () => {
|
|
387
|
+
const headers = {};
|
|
388
|
+
if (_insightsMode === "direct") {
|
|
389
|
+
const deviceToken = getFromStorage(storage_keys.device_token);
|
|
390
|
+
const sessionToken = getFromStorage(storage_keys.session_token);
|
|
391
|
+
if (deviceToken) headers["Levo-Audience-Device"] = deviceToken;
|
|
392
|
+
if (sessionToken) headers["Levo-Audience-Session"] = sessionToken;
|
|
393
|
+
}
|
|
394
|
+
return headers;
|
|
395
|
+
};
|
|
396
|
+
const pingEndpoint = async (url, timeout) => {
|
|
397
|
+
let timeoutId = null;
|
|
398
|
+
try {
|
|
399
|
+
const controller = new AbortController();
|
|
400
|
+
timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
401
|
+
const response = await fetch(url, {
|
|
402
|
+
method: "GET",
|
|
403
|
+
signal: controller.signal,
|
|
404
|
+
credentials: "omit"
|
|
405
|
+
});
|
|
406
|
+
return response.ok;
|
|
407
|
+
} catch {
|
|
408
|
+
return false;
|
|
409
|
+
} finally {
|
|
410
|
+
if (timeoutId) {
|
|
411
|
+
clearTimeout(timeoutId);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const detectInsightsMode = async () => {
|
|
416
|
+
if (insightsMode === "proxy") {
|
|
417
|
+
_insightsBaseUrl = insightsUrl;
|
|
418
|
+
return "proxy";
|
|
419
|
+
}
|
|
420
|
+
if (insightsMode === "direct") {
|
|
421
|
+
if (!directInsightsUrl) {
|
|
422
|
+
console.warn(
|
|
423
|
+
"[@levo-so/core] insightsMode='direct' but directInsightsUrl not set. Falling back to proxy."
|
|
424
|
+
);
|
|
425
|
+
_insightsBaseUrl = insightsUrl;
|
|
426
|
+
return "proxy";
|
|
427
|
+
}
|
|
428
|
+
_insightsBaseUrl = directInsightsUrl;
|
|
429
|
+
setInStorage(storage_keys.mode, "direct");
|
|
430
|
+
return "direct";
|
|
431
|
+
}
|
|
432
|
+
const cachedMode = getFromStorage(storage_keys.mode);
|
|
433
|
+
if (cachedMode === "proxy" || cachedMode === "direct") {
|
|
434
|
+
if (cachedMode === "direct" && !directInsightsUrl) {
|
|
435
|
+
removeFromStorage(storage_keys.mode);
|
|
436
|
+
_insightsBaseUrl = "";
|
|
437
|
+
} else {
|
|
438
|
+
const pingUrl = cachedMode === "proxy" ? `${insightsUrl}/ping` : `${directInsightsUrl}/ping`;
|
|
439
|
+
if (await pingEndpoint(pingUrl, pingTimeout)) {
|
|
440
|
+
_insightsBaseUrl = cachedMode === "proxy" ? insightsUrl : directInsightsUrl;
|
|
441
|
+
return cachedMode;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (await pingEndpoint(`${insightsUrl}/ping`, pingTimeout)) {
|
|
446
|
+
setInStorage(storage_keys.mode, "proxy");
|
|
447
|
+
_insightsBaseUrl = insightsUrl;
|
|
448
|
+
return "proxy";
|
|
449
|
+
}
|
|
450
|
+
if (directInsightsUrl) {
|
|
451
|
+
setInStorage(storage_keys.mode, "direct");
|
|
452
|
+
_insightsBaseUrl = directInsightsUrl;
|
|
453
|
+
return "direct";
|
|
454
|
+
}
|
|
455
|
+
_insightsBaseUrl = insightsUrl;
|
|
456
|
+
return "proxy";
|
|
457
|
+
};
|
|
458
|
+
let _session = "";
|
|
459
|
+
let _device = "";
|
|
460
|
+
let is_identified = false;
|
|
461
|
+
let _eventCount = 0;
|
|
462
|
+
let _eventQueue = [];
|
|
463
|
+
let _flushTimer = null;
|
|
464
|
+
let _visibilityHandler = null;
|
|
465
|
+
let _beforeUnloadHandler = null;
|
|
466
|
+
let _pageHideHandler = null;
|
|
467
|
+
let _channel = null;
|
|
468
|
+
let _pendingIdentify = false;
|
|
469
|
+
const buildSessionSyncMessage = () => {
|
|
470
|
+
const message = {
|
|
471
|
+
type: "SESSION_SYNC",
|
|
472
|
+
session: _session,
|
|
473
|
+
device: _device
|
|
474
|
+
};
|
|
475
|
+
if (_insightsMode === "direct") {
|
|
476
|
+
const storedDeviceToken = getFromStorage(storage_keys.device_token);
|
|
477
|
+
const storedSessionToken = getFromStorage(storage_keys.session_token);
|
|
478
|
+
if (storedDeviceToken) message.deviceToken = storedDeviceToken;
|
|
479
|
+
if (storedSessionToken) message.sessionToken = storedSessionToken;
|
|
480
|
+
}
|
|
481
|
+
return message;
|
|
482
|
+
};
|
|
483
|
+
const initBroadcastChannel = () => {
|
|
484
|
+
if (typeof BroadcastChannel === "undefined") {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
_channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
|
|
489
|
+
_channel.onmessage = (event) => {
|
|
490
|
+
const { type, session, device, deviceToken, sessionToken } = event.data;
|
|
491
|
+
if (type === "SESSION_SYNC" && !is_identified) {
|
|
492
|
+
_session = session;
|
|
493
|
+
_device = device;
|
|
494
|
+
is_identified = true;
|
|
495
|
+
_pendingIdentify = false;
|
|
496
|
+
if (_insightsMode === "direct" && deviceToken && sessionToken) {
|
|
497
|
+
setInStorage(storage_keys.device_token, deviceToken);
|
|
498
|
+
setInStorage(storage_keys.session_token, sessionToken);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (type === "SESSION_REQUEST" && is_identified) {
|
|
502
|
+
_channel?.postMessage(buildSessionSyncMessage());
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
} catch {
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
const requestSessionFromOtherTabs = () => {
|
|
509
|
+
_channel?.postMessage({ type: "SESSION_REQUEST" });
|
|
510
|
+
};
|
|
511
|
+
const broadcastSession = () => {
|
|
512
|
+
_channel?.postMessage(buildSessionSyncMessage());
|
|
513
|
+
};
|
|
514
|
+
const flushEventQueue = () => {
|
|
515
|
+
const baseUrl = _insightsBaseUrl || insightsUrl;
|
|
516
|
+
if (_eventQueue.length === 0 || !baseUrl || !workspace) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const batch = _eventQueue.map((event) => ({
|
|
520
|
+
...event,
|
|
521
|
+
session_id: event.session_id || _session,
|
|
522
|
+
device_id: event.device_id || _device
|
|
523
|
+
}));
|
|
524
|
+
_eventQueue = [];
|
|
525
|
+
const url = `${baseUrl}/v1/insights/event/bulk?workspace=${workspace}`;
|
|
526
|
+
const headers = {
|
|
527
|
+
"Content-Type": "application/json",
|
|
528
|
+
...getAuthHeaders()
|
|
529
|
+
};
|
|
530
|
+
if (_insightsMode === "proxy" && navigator.sendBeacon) {
|
|
531
|
+
const success = navigator.sendBeacon(url, JSON.stringify(batch));
|
|
532
|
+
if (success) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
fetch(url, {
|
|
537
|
+
method: "POST",
|
|
538
|
+
body: JSON.stringify(batch),
|
|
539
|
+
headers,
|
|
540
|
+
keepalive: true,
|
|
541
|
+
credentials: _insightsMode === "proxy" ? "include" : "omit"
|
|
542
|
+
}).catch(() => {
|
|
543
|
+
});
|
|
544
|
+
};
|
|
545
|
+
const startFlushTimer = () => {
|
|
546
|
+
if (_flushTimer) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
_flushTimer = setInterval(() => {
|
|
550
|
+
flushEventQueue();
|
|
551
|
+
}, BATCH_INTERVAL_MS);
|
|
552
|
+
if (typeof document !== "undefined") {
|
|
553
|
+
_visibilityHandler = () => {
|
|
554
|
+
if (document.visibilityState === "hidden") {
|
|
555
|
+
flushEventQueue();
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
_beforeUnloadHandler = () => {
|
|
559
|
+
flushEventQueue();
|
|
560
|
+
};
|
|
561
|
+
_pageHideHandler = () => {
|
|
562
|
+
flushEventQueue();
|
|
563
|
+
};
|
|
564
|
+
document.addEventListener("visibilitychange", _visibilityHandler);
|
|
565
|
+
window.addEventListener("beforeunload", _beforeUnloadHandler);
|
|
566
|
+
window.addEventListener("pagehide", _pageHideHandler);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const stopFlushTimer = () => {
|
|
570
|
+
if (_flushTimer) {
|
|
571
|
+
clearInterval(_flushTimer);
|
|
572
|
+
_flushTimer = null;
|
|
573
|
+
}
|
|
574
|
+
if (typeof document !== "undefined") {
|
|
575
|
+
if (_visibilityHandler) {
|
|
576
|
+
document.removeEventListener("visibilitychange", _visibilityHandler);
|
|
577
|
+
_visibilityHandler = null;
|
|
578
|
+
}
|
|
579
|
+
if (_beforeUnloadHandler) {
|
|
580
|
+
window.removeEventListener("beforeunload", _beforeUnloadHandler);
|
|
581
|
+
_beforeUnloadHandler = null;
|
|
582
|
+
}
|
|
583
|
+
if (_pageHideHandler) {
|
|
584
|
+
window.removeEventListener("pagehide", _pageHideHandler);
|
|
585
|
+
_pageHideHandler = null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
const queueEvent = (collectInput) => {
|
|
590
|
+
if (_eventCount >= MAX_EVENTS_PER_PAGE) {
|
|
591
|
+
console.warn(`[@levo-so/core] Event limit (${MAX_EVENTS_PER_PAGE}) reached for this page`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
_eventCount++;
|
|
595
|
+
_eventQueue.push(collectInput);
|
|
596
|
+
if (_eventQueue.length >= BATCH_SIZE) {
|
|
597
|
+
flushEventQueue();
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
const getReferrer = () => {
|
|
601
|
+
if (typeof document === "undefined") {
|
|
602
|
+
return {};
|
|
603
|
+
}
|
|
604
|
+
const {
|
|
605
|
+
type,
|
|
606
|
+
referrer,
|
|
607
|
+
data = {}
|
|
608
|
+
} = getVisitorSource({
|
|
609
|
+
referrer: document.referrer,
|
|
610
|
+
currentUrl: window.location.href,
|
|
611
|
+
mapper: null
|
|
612
|
+
});
|
|
613
|
+
const dataKeys = Object.keys(data);
|
|
614
|
+
for (const key of dataKeys) {
|
|
615
|
+
if (data[key] && Array.isArray(data[key]) && data[key].length > 0) {
|
|
616
|
+
data[key] = data[key]?.[0];
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return { type, referrer, data, raw: document.referrer };
|
|
620
|
+
};
|
|
621
|
+
const getCurrentTabId = () => {
|
|
622
|
+
try {
|
|
623
|
+
if (typeof window === "undefined" || !("sessionStorage" in window)) {
|
|
624
|
+
return "";
|
|
625
|
+
}
|
|
626
|
+
return window.sessionStorage.getItem(storage_keys.current_tab) || "";
|
|
627
|
+
} catch {
|
|
628
|
+
return "";
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
const getTabCount = () => {
|
|
632
|
+
try {
|
|
633
|
+
if (typeof window === "undefined" || !("localStorage" in window)) {
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
return Number(window.localStorage.getItem(storage_keys.open_tabs)) || 0;
|
|
637
|
+
} catch {
|
|
638
|
+
return 0;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
const getActivityStatus = () => {
|
|
642
|
+
const status = idle_listener?.getStatus();
|
|
643
|
+
if (!status) {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
status: status.isIdle ? "idle" : "active",
|
|
648
|
+
seconds: status.isIdle ? status.idle : status.active
|
|
649
|
+
};
|
|
650
|
+
};
|
|
651
|
+
const identify = async (options2) => {
|
|
652
|
+
if (!instance) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const withLock = options2?.withLock ?? true;
|
|
656
|
+
const referrer = getReferrer();
|
|
657
|
+
const traits = await getUserProperties();
|
|
658
|
+
await instance.identify("", {
|
|
659
|
+
...traits,
|
|
660
|
+
referrer,
|
|
661
|
+
withLock
|
|
662
|
+
});
|
|
663
|
+
};
|
|
664
|
+
const request = async (url, data) => {
|
|
665
|
+
const headers = {
|
|
666
|
+
Accept: "application/json",
|
|
667
|
+
"Content-Type": "application/json",
|
|
668
|
+
...getAuthHeaders()
|
|
669
|
+
};
|
|
670
|
+
try {
|
|
671
|
+
const response = await levoFetch.url(_insightsBaseUrl || insightsUrl, true).url(url).options({
|
|
672
|
+
keepAlive: true,
|
|
673
|
+
credentials: _insightsMode === "proxy" ? "include" : "omit"
|
|
674
|
+
}).headers(headers).post(data).json();
|
|
675
|
+
return response;
|
|
676
|
+
} catch (error) {
|
|
677
|
+
console.warn(`Analytics request failed for ${url}:`, error);
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
const initiate = async (properties) => {
|
|
682
|
+
if (NODE_ENV === "development") {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
_eventCount = 0;
|
|
686
|
+
_localStorageAvailable = checkLocalStorageAvailable();
|
|
687
|
+
_insightsMode = await detectInsightsMode();
|
|
688
|
+
initBroadcastChannel();
|
|
689
|
+
requestSessionFromOtherTabs();
|
|
690
|
+
startFlushTimer();
|
|
691
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
692
|
+
const plugin = {
|
|
693
|
+
name: workspace,
|
|
694
|
+
/**
|
|
695
|
+
* Handle track events (custom events like clicks, scrolls, etc.)
|
|
696
|
+
*
|
|
697
|
+
* Enriches the event with:
|
|
698
|
+
* - Session/device IDs
|
|
699
|
+
* - Page metadata (URL, title, etc.)
|
|
700
|
+
* - Tab information
|
|
701
|
+
* - Timestamp
|
|
702
|
+
*
|
|
703
|
+
* Then queues for batched sending.
|
|
704
|
+
*/
|
|
705
|
+
track: ({ payload }) => {
|
|
706
|
+
if (!insightsUrl || !workspace) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const payloadProperties = payload?.properties || {};
|
|
710
|
+
const collectInput = {
|
|
711
|
+
session_id: _session,
|
|
712
|
+
device_id: _device,
|
|
713
|
+
event: payload.event,
|
|
714
|
+
version: payload.version || 1,
|
|
715
|
+
workspace_id: workspace,
|
|
716
|
+
site_id: site || void 0,
|
|
717
|
+
properties: {
|
|
718
|
+
...properties,
|
|
719
|
+
...payloadProperties,
|
|
720
|
+
// Remove metadata fields from properties (they're top-level)
|
|
721
|
+
version: void 0,
|
|
722
|
+
resource: void 0,
|
|
723
|
+
identifier: void 0,
|
|
724
|
+
hostname: void 0,
|
|
725
|
+
pathname: void 0,
|
|
726
|
+
url: void 0,
|
|
727
|
+
page_title: void 0,
|
|
728
|
+
created_at: void 0
|
|
729
|
+
},
|
|
730
|
+
hostname: payload?.hostname || window.location.hostname,
|
|
731
|
+
pathname: payload?.pathname || window.location.pathname,
|
|
732
|
+
url: payload?.url || window.location.href,
|
|
733
|
+
page_title: payload?.page_title || document.title,
|
|
734
|
+
resource: payloadProperties?.resource || "page",
|
|
735
|
+
identifier: payloadProperties?.identifier || properties?.id,
|
|
736
|
+
created_at: payload?.created_at || (/* @__PURE__ */ new Date()).toISOString(),
|
|
737
|
+
tab_id: getCurrentTabId(),
|
|
738
|
+
tab_count: getTabCount()
|
|
739
|
+
};
|
|
740
|
+
queueEvent(collectInput);
|
|
741
|
+
},
|
|
742
|
+
/**
|
|
743
|
+
* Handle page view events.
|
|
744
|
+
*
|
|
745
|
+
* Called when navigating to a new page (in SPAs) or on initial load.
|
|
746
|
+
* Resets the rate limit counter and tracks a page.view event.
|
|
747
|
+
*/
|
|
748
|
+
page: ({ payload }) => {
|
|
749
|
+
if (!insightsUrl) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
_eventCount = 0;
|
|
753
|
+
const payloadProperties = payload?.properties || {};
|
|
754
|
+
track("page.view", {
|
|
755
|
+
...properties,
|
|
756
|
+
...payloadProperties,
|
|
757
|
+
title: document.title,
|
|
758
|
+
identifier: properties.id,
|
|
759
|
+
resource: "page"
|
|
760
|
+
});
|
|
761
|
+
},
|
|
762
|
+
/**
|
|
763
|
+
* Handle identify events (session establishment).
|
|
764
|
+
*
|
|
765
|
+
* This is called when identify() is invoked. It:
|
|
766
|
+
* 1. Checks if already identified (via BroadcastChannel) - if so, skip
|
|
767
|
+
* 2. Calls /welcome API to get session/device IDs
|
|
768
|
+
* 3. On success, broadcasts session to other tabs
|
|
769
|
+
*
|
|
770
|
+
* The /welcome API uses HttpOnly cookies to maintain persistent
|
|
771
|
+
* device identity across sessions.
|
|
772
|
+
*/
|
|
773
|
+
identify: ({ payload }) => {
|
|
774
|
+
if (!insightsUrl || !workspace) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (is_identified) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (_pendingIdentify) {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
_pendingIdentify = true;
|
|
784
|
+
const welcomeInput = {
|
|
785
|
+
private_mode: payload.traits.private_mode,
|
|
786
|
+
dark_mode: payload.traits.dark_mode,
|
|
787
|
+
timezone: payload.traits.timezone,
|
|
788
|
+
locale: payload.traits.locale,
|
|
789
|
+
referrer: payload.traits.referrer,
|
|
790
|
+
properties: payload.traits.properties,
|
|
791
|
+
workspace_id: workspace,
|
|
792
|
+
site_id: site || void 0,
|
|
793
|
+
hostname: window.location.hostname,
|
|
794
|
+
pathname: window.location.pathname,
|
|
795
|
+
page_title: document.title,
|
|
796
|
+
url: window.location.href,
|
|
797
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
798
|
+
tab_id: getCurrentTabId(),
|
|
799
|
+
tab_count: getTabCount()
|
|
800
|
+
};
|
|
801
|
+
request(
|
|
802
|
+
`/v1/insights/event/welcome`,
|
|
803
|
+
welcomeInput
|
|
804
|
+
).then((data) => {
|
|
805
|
+
if (is_identified) {
|
|
806
|
+
_pendingIdentify = false;
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
is_identified = true;
|
|
810
|
+
_pendingIdentify = false;
|
|
811
|
+
if (data?.content?.data?.session) {
|
|
812
|
+
_session = data?.content.data.session;
|
|
813
|
+
}
|
|
814
|
+
if (data?.content?.data?.device) {
|
|
815
|
+
_device = data?.content.data.device;
|
|
816
|
+
}
|
|
817
|
+
if (_insightsMode === "direct" && data?.content?.meta) {
|
|
818
|
+
const meta = data.content.meta;
|
|
819
|
+
if (meta.device_token) {
|
|
820
|
+
setInStorage(storage_keys.device_token, meta.device_token);
|
|
821
|
+
}
|
|
822
|
+
if (meta.session_token) {
|
|
823
|
+
setInStorage(storage_keys.session_token, meta.session_token);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
broadcastSession();
|
|
827
|
+
}).catch((e) => {
|
|
828
|
+
_pendingIdentify = false;
|
|
829
|
+
return getLevoError(e);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
instance = Analytics({
|
|
834
|
+
app: workspace || "",
|
|
835
|
+
plugins: [plugin]
|
|
836
|
+
});
|
|
837
|
+
await identify();
|
|
838
|
+
startActivityListener();
|
|
839
|
+
return instance.page({
|
|
840
|
+
resource: "page",
|
|
841
|
+
identifier: properties.id
|
|
842
|
+
});
|
|
843
|
+
};
|
|
844
|
+
const startActivityListener = () => {
|
|
845
|
+
if (typeof document !== "undefined" && !idle_listener) {
|
|
846
|
+
idle_listener = onUserActivity({
|
|
847
|
+
timeout: _IDLE_TIMEOUT,
|
|
848
|
+
throttle: _IDLE_THROTTLE,
|
|
849
|
+
onIdle: (activeForSeconds) => {
|
|
850
|
+
track("user.idle", {
|
|
851
|
+
seconds_active: activeForSeconds
|
|
852
|
+
});
|
|
853
|
+
},
|
|
854
|
+
onWakeUp: (idleForSeconds) => {
|
|
855
|
+
track("user.active", {
|
|
856
|
+
seconds_idle: idleForSeconds
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
return null;
|
|
862
|
+
};
|
|
863
|
+
const bounce = async (properties) => {
|
|
864
|
+
const activity_status = getActivityStatus();
|
|
865
|
+
if (activity_status) {
|
|
866
|
+
if (activity_status?.status === "idle") {
|
|
867
|
+
properties.seconds_idle = activity_status.seconds;
|
|
868
|
+
} else {
|
|
869
|
+
properties.seconds_active = activity_status.seconds;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
track("page.bounce", properties);
|
|
873
|
+
};
|
|
874
|
+
const track = async (event, properties) => {
|
|
875
|
+
if (!workspace) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (instance) {
|
|
879
|
+
return instance.track(event, properties);
|
|
880
|
+
}
|
|
881
|
+
const collectInput = {
|
|
882
|
+
event,
|
|
883
|
+
version: properties?.version || 1,
|
|
884
|
+
properties: {
|
|
885
|
+
...properties,
|
|
886
|
+
version: void 0,
|
|
887
|
+
resource: void 0,
|
|
888
|
+
identifier: void 0,
|
|
889
|
+
hostname: void 0,
|
|
890
|
+
pathname: void 0,
|
|
891
|
+
url: void 0,
|
|
892
|
+
page_title: void 0,
|
|
893
|
+
created_at: void 0
|
|
894
|
+
},
|
|
895
|
+
hostname: window.location.hostname,
|
|
896
|
+
pathname: window.location.pathname,
|
|
897
|
+
url: window.location.href,
|
|
898
|
+
page_title: document.title,
|
|
899
|
+
resource: properties?.resource || "page",
|
|
900
|
+
identifier: properties?.identifier || properties?.id,
|
|
901
|
+
workspace_id: workspace,
|
|
902
|
+
site_id: site || void 0,
|
|
903
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
904
|
+
tab_id: getCurrentTabId(),
|
|
905
|
+
tab_count: getTabCount()
|
|
906
|
+
};
|
|
907
|
+
queueEvent(collectInput);
|
|
908
|
+
};
|
|
909
|
+
const destroy = () => {
|
|
910
|
+
flushEventQueue();
|
|
911
|
+
stopFlushTimer();
|
|
912
|
+
_channel?.close();
|
|
913
|
+
};
|
|
914
|
+
return {
|
|
915
|
+
get insightsUrl() {
|
|
916
|
+
return insightsUrl;
|
|
917
|
+
},
|
|
918
|
+
/** Get the underlying analytics instance (read-only) */
|
|
919
|
+
get instance() {
|
|
920
|
+
return instance;
|
|
921
|
+
},
|
|
922
|
+
/** Check if session has been established (read-only) */
|
|
923
|
+
get is_identified() {
|
|
924
|
+
return is_identified;
|
|
925
|
+
},
|
|
926
|
+
/** Storage keys used by the module */
|
|
927
|
+
storage_keys,
|
|
928
|
+
/** Initialize the module for a page */
|
|
929
|
+
initiate,
|
|
930
|
+
/** Identify/establish session */
|
|
931
|
+
identify,
|
|
932
|
+
/** Track a custom event */
|
|
933
|
+
track,
|
|
934
|
+
/** Track a bounce event */
|
|
935
|
+
bounce,
|
|
936
|
+
/** Cleanup the module */
|
|
937
|
+
destroy
|
|
938
|
+
};
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
export { AnalyticsEvents, AnalyticsEventsList, createLevoInsights };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@levo-so/insights",
|
|
3
|
+
"version": "0.1.61",
|
|
4
|
+
"author": "Levo Engineering <devs@theinternetfolks.com>",
|
|
5
|
+
"description": "Levo analytics",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/**",
|
|
13
|
+
"!dist/**/*.d.ts.map"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"clean": "rimraf dist node_modules .turbo",
|
|
17
|
+
"check-types": "tsc --noEmit",
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"dev": "tsup --watch",
|
|
20
|
+
"demo:serve": "python3 -m http.server 8080",
|
|
21
|
+
"demo": "pnpm build && pnpm demo:serve"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@analytics/activity-utils": "0.2.2",
|
|
25
|
+
"@analytics/visitor-source": "0.0.7",
|
|
26
|
+
"@levo-so/core": "workspace:*",
|
|
27
|
+
"analytics": "0.8.14",
|
|
28
|
+
"get-user-locale": "2.3.2",
|
|
29
|
+
"lodash-es": "4.17.21"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@levo/ts-config": "workspace:*",
|
|
33
|
+
"@types/lodash-es": "4.17.12",
|
|
34
|
+
"tsup": "8.2.4",
|
|
35
|
+
"typescript": "5.9.3"
|
|
36
|
+
},
|
|
37
|
+
"main": "./dist/index.js",
|
|
38
|
+
"module": "./dist/index.js",
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"exports": {
|
|
41
|
+
".": {
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
43
|
+
"default": "./dist/index.js"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|