@fire-spark/analytics 0.0.1
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 +124 -0
- package/dist/index.js +557 -0
- package/package.json +56 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
declare const FUNNEL_EVENTS: readonly ["menu_viewed", "product_viewed", "cart_item_added", "order_placed"];
|
|
3
|
+
declare const INTERACTION_EVENTS: readonly ["product_visited", "category_visited"];
|
|
4
|
+
type FunnelEventName = (typeof FUNNEL_EVENTS)[number];
|
|
5
|
+
type InteractionEventName = (typeof INTERACTION_EVENTS)[number];
|
|
6
|
+
type AnalyticsPlatform = "web" | "ios" | "android";
|
|
7
|
+
type AnalyticsCustomer = {
|
|
8
|
+
id: string;
|
|
9
|
+
is_anonymous: boolean;
|
|
10
|
+
};
|
|
11
|
+
type AnalyticsContext = {
|
|
12
|
+
os_name?: string;
|
|
13
|
+
os_version?: string;
|
|
14
|
+
app_version?: string;
|
|
15
|
+
app_build?: string;
|
|
16
|
+
device_type?: string;
|
|
17
|
+
device_model?: string;
|
|
18
|
+
browser?: string;
|
|
19
|
+
browser_version?: string;
|
|
20
|
+
locale?: string;
|
|
21
|
+
timezone?: string;
|
|
22
|
+
};
|
|
23
|
+
type AnalyticsScope = {
|
|
24
|
+
store_id?: string;
|
|
25
|
+
channel_id?: string;
|
|
26
|
+
brand_id?: string;
|
|
27
|
+
fulfillment_id?: string;
|
|
28
|
+
};
|
|
29
|
+
type MenuViewedEvent = {
|
|
30
|
+
name: "menu_viewed";
|
|
31
|
+
properties?: Record<string, unknown>;
|
|
32
|
+
};
|
|
33
|
+
type ProductViewedEvent = {
|
|
34
|
+
name: "product_viewed";
|
|
35
|
+
properties: {
|
|
36
|
+
product_id: string;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
type CartItemAddedEvent = {
|
|
40
|
+
name: "cart_item_added";
|
|
41
|
+
properties: {
|
|
42
|
+
product_id: string;
|
|
43
|
+
quantity: number;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
type OrderPlacedEvent = {
|
|
47
|
+
name: "order_placed";
|
|
48
|
+
properties: {
|
|
49
|
+
order_id: string;
|
|
50
|
+
total?: number;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
type ProductVisitedEvent = {
|
|
54
|
+
name: "product_visited";
|
|
55
|
+
properties: {
|
|
56
|
+
product_id: string;
|
|
57
|
+
category_id?: string;
|
|
58
|
+
attribution_source?: string;
|
|
59
|
+
attribution_id?: string;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
type CategoryVisitedEvent = {
|
|
63
|
+
name: "category_visited";
|
|
64
|
+
properties: {
|
|
65
|
+
category_id: string;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
type FunnelTrackEvent = MenuViewedEvent | ProductViewedEvent | CartItemAddedEvent | OrderPlacedEvent;
|
|
69
|
+
type InteractionTrackEvent = ProductVisitedEvent | CategoryVisitedEvent;
|
|
70
|
+
type FreeTrackEvent = {
|
|
71
|
+
name: string;
|
|
72
|
+
properties?: Record<string, unknown>;
|
|
73
|
+
};
|
|
74
|
+
type TrackEvent = FunnelTrackEvent | InteractionTrackEvent | FreeTrackEvent;
|
|
75
|
+
type AnalyticsStorage = {
|
|
76
|
+
getItem: (key: string) => string | null | Promise<string | null>;
|
|
77
|
+
setItem: (key: string, value: string) => void | Promise<void>;
|
|
78
|
+
removeItem: (key: string) => void | Promise<void>;
|
|
79
|
+
};
|
|
80
|
+
type AnalyticsLimits = {
|
|
81
|
+
maxEvents?: number;
|
|
82
|
+
maxAgeMs?: number;
|
|
83
|
+
maxBytes?: number;
|
|
84
|
+
};
|
|
85
|
+
type AnalyticsConfigureOptions = {
|
|
86
|
+
baseUrl?: string;
|
|
87
|
+
getAccessToken?: () => Promise<string>;
|
|
88
|
+
storage: AnalyticsStorage;
|
|
89
|
+
getCustomer: () => AnalyticsCustomer | null;
|
|
90
|
+
getScope?: () => AnalyticsScope;
|
|
91
|
+
platform: AnalyticsPlatform;
|
|
92
|
+
context?: AnalyticsContext;
|
|
93
|
+
isOnline?: () => boolean | Promise<boolean>;
|
|
94
|
+
limits?: AnalyticsLimits;
|
|
95
|
+
fetchImpl?: typeof fetch;
|
|
96
|
+
sessionMs?: number;
|
|
97
|
+
};
|
|
98
|
+
type AnalyticsConfig = {
|
|
99
|
+
session: {
|
|
100
|
+
value: number;
|
|
101
|
+
unit: "s" | "min" | "h";
|
|
102
|
+
};
|
|
103
|
+
session_ms: number;
|
|
104
|
+
};
|
|
105
|
+
declare const isFunnelEventName: (name: string) => name is FunnelEventName;
|
|
106
|
+
declare const isInteractionEventName: (name: string) => name is InteractionEventName;
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/flush.d.ts
|
|
109
|
+
declare const flush: () => Promise<void>;
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/index.d.ts
|
|
112
|
+
declare const configure: (options: AnalyticsConfigureOptions) => Promise<void>;
|
|
113
|
+
declare const track: (event: TrackEvent) => Promise<void>;
|
|
114
|
+
declare const endSession: () => Promise<void>;
|
|
115
|
+
declare const reset: () => Promise<void>;
|
|
116
|
+
declare const analytics: {
|
|
117
|
+
configure: typeof configure;
|
|
118
|
+
track: typeof track;
|
|
119
|
+
flush: typeof flush;
|
|
120
|
+
endSession: typeof endSession;
|
|
121
|
+
reset: typeof reset;
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
124
|
+
export { type AnalyticsConfig, type AnalyticsConfigureOptions, type AnalyticsContext, type AnalyticsCustomer, type AnalyticsPlatform, type AnalyticsScope, type AnalyticsStorage, FUNNEL_EVENTS, type FunnelEventName, INTERACTION_EVENTS, type InteractionEventName, type TrackEvent, analytics, configure, endSession, isFunnelEventName, isInteractionEventName, reset, track };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { StorefrontHttpError, storefrontJson } from "@fire-spark/core";
|
|
2
|
+
//#region src/context.ts
|
|
3
|
+
const SDK_VERSION = "0.0.1";
|
|
4
|
+
const intlContext = () => {
|
|
5
|
+
try {
|
|
6
|
+
const options = Intl.DateTimeFormat().resolvedOptions();
|
|
7
|
+
return {
|
|
8
|
+
locale: options.locale,
|
|
9
|
+
timezone: options.timeZone
|
|
10
|
+
};
|
|
11
|
+
} catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const compact = (context) => {
|
|
16
|
+
const next = {};
|
|
17
|
+
for (const [key, raw] of Object.entries(context)) {
|
|
18
|
+
if (typeof raw !== "string") continue;
|
|
19
|
+
const value = raw.trim().slice(0, 64);
|
|
20
|
+
if (value) next[key] = value;
|
|
21
|
+
}
|
|
22
|
+
return next;
|
|
23
|
+
};
|
|
24
|
+
const buildEventContext = (context) => {
|
|
25
|
+
const merged = {
|
|
26
|
+
...intlContext(),
|
|
27
|
+
...context
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
...compact(merged),
|
|
31
|
+
sdk_version: SDK_VERSION
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/retry.ts
|
|
36
|
+
const defaultSleep = (ms) => {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
setTimeout(resolve, ms);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
/** 1 attempt + `extraAttempts` retries with exponential backoff. */
|
|
42
|
+
const withExponentialRetry = async (run, options = {}) => {
|
|
43
|
+
const extraAttempts = options.extraAttempts ?? 3;
|
|
44
|
+
const baseDelayMs = options.baseDelayMs ?? 100;
|
|
45
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
46
|
+
const shouldRetry = options.shouldRetry ?? (() => true);
|
|
47
|
+
let lastError;
|
|
48
|
+
for (let attempt = 0; attempt <= extraAttempts; attempt += 1) try {
|
|
49
|
+
return await run();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
lastError = error;
|
|
52
|
+
if (attempt === extraAttempts) break;
|
|
53
|
+
if (!shouldRetry(error)) break;
|
|
54
|
+
await sleep(baseDelayMs * 2 ** attempt);
|
|
55
|
+
}
|
|
56
|
+
throw lastError;
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/types.ts
|
|
60
|
+
const FUNNEL_EVENTS = [
|
|
61
|
+
"menu_viewed",
|
|
62
|
+
"product_viewed",
|
|
63
|
+
"cart_item_added",
|
|
64
|
+
"order_placed"
|
|
65
|
+
];
|
|
66
|
+
const INTERACTION_EVENTS = ["product_visited", "category_visited"];
|
|
67
|
+
const isFunnelEventName = (name) => {
|
|
68
|
+
return FUNNEL_EVENTS.includes(name);
|
|
69
|
+
};
|
|
70
|
+
const isInteractionEventName = (name) => {
|
|
71
|
+
return INTERACTION_EVENTS.includes(name);
|
|
72
|
+
};
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/interactions.ts
|
|
75
|
+
const INTERACTIONS_KEY = "firespark:analytics:interactions";
|
|
76
|
+
const interactionKey = (name, entityId) => {
|
|
77
|
+
return `${name}:${entityId}`;
|
|
78
|
+
};
|
|
79
|
+
const parseInteractions = (raw) => {
|
|
80
|
+
if (!raw) return null;
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(raw);
|
|
83
|
+
if (!parsed?.session_id || !Array.isArray(parsed.keys)) return null;
|
|
84
|
+
return parsed;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const bindInteractions = (state, sessionId) => {
|
|
90
|
+
if (!state || state.session_id !== sessionId) return {
|
|
91
|
+
session_id: sessionId,
|
|
92
|
+
keys: []
|
|
93
|
+
};
|
|
94
|
+
return state;
|
|
95
|
+
};
|
|
96
|
+
const shouldSkipInteraction = (state, name, entityId) => {
|
|
97
|
+
if (!isInteractionEventName(name) || !entityId) return false;
|
|
98
|
+
return state.keys.includes(interactionKey(name, entityId));
|
|
99
|
+
};
|
|
100
|
+
const markInteraction = (state, name, entityId) => {
|
|
101
|
+
if (!isInteractionEventName(name) || !entityId) return state;
|
|
102
|
+
const key = interactionKey(name, entityId);
|
|
103
|
+
if (state.keys.includes(key)) return state;
|
|
104
|
+
const keys = [...state.keys, key].slice(-500);
|
|
105
|
+
return {
|
|
106
|
+
...state,
|
|
107
|
+
keys
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/queue.ts
|
|
112
|
+
const QUEUE_KEY = "firespark:analytics:queue";
|
|
113
|
+
const parseQueue = (raw) => {
|
|
114
|
+
if (!raw) return [];
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(raw);
|
|
117
|
+
if (!Array.isArray(parsed)) return [];
|
|
118
|
+
return parsed.filter((item) => item?.id && item.name && item.session_id);
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const eventAgeMs = (event, now) => {
|
|
124
|
+
const at = Date.parse(event.timestamp);
|
|
125
|
+
if (!Number.isFinite(at)) return Number.POSITIVE_INFINITY;
|
|
126
|
+
return now - at;
|
|
127
|
+
};
|
|
128
|
+
const pruneQueue = (events, now, limits) => {
|
|
129
|
+
let next = events.filter((event) => eventAgeMs(event, now) <= limits.maxAgeMs);
|
|
130
|
+
if (next.length > limits.maxEvents) next = next.slice(next.length - limits.maxEvents);
|
|
131
|
+
while (next.length > 0 && JSON.stringify(next).length > limits.maxBytes) next = next.slice(1);
|
|
132
|
+
return next;
|
|
133
|
+
};
|
|
134
|
+
const enqueueEvent = (events, event, now, limits) => {
|
|
135
|
+
return pruneQueue([...events, event], now, limits);
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/runtime.ts
|
|
139
|
+
const CONFIG_KEY = "firespark:analytics:config";
|
|
140
|
+
const DEFAULT_SESSION_MS = 1800 * 1e3;
|
|
141
|
+
const RETRY_MS = 6e4;
|
|
142
|
+
const runtime = {
|
|
143
|
+
options: null,
|
|
144
|
+
context: null,
|
|
145
|
+
sessionMs: DEFAULT_SESSION_MS,
|
|
146
|
+
session: null,
|
|
147
|
+
interactions: null,
|
|
148
|
+
queue: [],
|
|
149
|
+
hydrated: false,
|
|
150
|
+
sessionTimer: null,
|
|
151
|
+
retryTimer: null
|
|
152
|
+
};
|
|
153
|
+
const getLimits = () => {
|
|
154
|
+
const limits = runtime.options?.limits;
|
|
155
|
+
return {
|
|
156
|
+
maxEvents: limits?.maxEvents ?? 500,
|
|
157
|
+
maxAgeMs: limits?.maxAgeMs ?? 6048e5,
|
|
158
|
+
maxBytes: limits?.maxBytes ?? 1e6
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
const readStorage = async (key) => {
|
|
162
|
+
const storage = runtime.options?.storage;
|
|
163
|
+
if (!storage) return null;
|
|
164
|
+
return storage.getItem(key);
|
|
165
|
+
};
|
|
166
|
+
const writeStorage = async (key, value) => {
|
|
167
|
+
const storage = runtime.options?.storage;
|
|
168
|
+
if (!storage) return;
|
|
169
|
+
await storage.setItem(key, value);
|
|
170
|
+
};
|
|
171
|
+
const clearStorage = async (key) => {
|
|
172
|
+
const storage = runtime.options?.storage;
|
|
173
|
+
if (!storage) return;
|
|
174
|
+
await storage.removeItem(key);
|
|
175
|
+
};
|
|
176
|
+
const persistSession = async () => {
|
|
177
|
+
const key = "firespark:analytics:session";
|
|
178
|
+
if (!runtime.session) {
|
|
179
|
+
await clearStorage(key);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const p1 = [key, JSON.stringify(runtime.session)];
|
|
183
|
+
await writeStorage(...p1);
|
|
184
|
+
};
|
|
185
|
+
const persistQueue = async () => {
|
|
186
|
+
const p1 = ["firespark:analytics:queue", JSON.stringify(runtime.queue)];
|
|
187
|
+
await writeStorage(...p1);
|
|
188
|
+
};
|
|
189
|
+
const persistConfig = async (sessionMs) => {
|
|
190
|
+
await writeStorage(CONFIG_KEY, JSON.stringify({ session_ms: sessionMs }));
|
|
191
|
+
};
|
|
192
|
+
const persistInteractions = async () => {
|
|
193
|
+
if (!runtime.interactions) {
|
|
194
|
+
await clearStorage(INTERACTIONS_KEY);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const p1 = [INTERACTIONS_KEY, JSON.stringify(runtime.interactions)];
|
|
198
|
+
await writeStorage(...p1);
|
|
199
|
+
};
|
|
200
|
+
const clearTimer = (key) => {
|
|
201
|
+
const timer = runtime[key];
|
|
202
|
+
if (timer) clearTimeout(timer);
|
|
203
|
+
runtime[key] = null;
|
|
204
|
+
};
|
|
205
|
+
const resetRuntime = () => {
|
|
206
|
+
clearTimer("sessionTimer");
|
|
207
|
+
clearTimer("retryTimer");
|
|
208
|
+
runtime.options = null;
|
|
209
|
+
runtime.context = null;
|
|
210
|
+
runtime.sessionMs = DEFAULT_SESSION_MS;
|
|
211
|
+
runtime.session = null;
|
|
212
|
+
runtime.interactions = null;
|
|
213
|
+
runtime.queue = [];
|
|
214
|
+
runtime.hydrated = false;
|
|
215
|
+
};
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/flush.ts
|
|
218
|
+
const isPoisonChunk = (error) => {
|
|
219
|
+
if (!(error instanceof StorefrontHttpError)) return false;
|
|
220
|
+
return error.status === 400 || error.status === 422;
|
|
221
|
+
};
|
|
222
|
+
const isTransientError = (error) => {
|
|
223
|
+
if (!(error instanceof StorefrontHttpError)) return true;
|
|
224
|
+
if (error.status === 429) return true;
|
|
225
|
+
return error.status >= 500;
|
|
226
|
+
};
|
|
227
|
+
const httpOptions = async () => {
|
|
228
|
+
const options = runtime.options;
|
|
229
|
+
if (!options) return {};
|
|
230
|
+
const accessToken = options.getAccessToken ? await options.getAccessToken() : void 0;
|
|
231
|
+
return {
|
|
232
|
+
baseUrl: options.baseUrl,
|
|
233
|
+
accessToken,
|
|
234
|
+
fetchImpl: options.fetchImpl
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
const isOnline = async () => {
|
|
238
|
+
const check = runtime.options?.isOnline;
|
|
239
|
+
if (!check) return true;
|
|
240
|
+
return check();
|
|
241
|
+
};
|
|
242
|
+
const postChunk = async (events) => {
|
|
243
|
+
const http = await httpOptions();
|
|
244
|
+
return storefrontJson(...[
|
|
245
|
+
"/api/storefront/v1/analytics/events",
|
|
246
|
+
{
|
|
247
|
+
method: "POST",
|
|
248
|
+
body: JSON.stringify({ events })
|
|
249
|
+
},
|
|
250
|
+
http
|
|
251
|
+
]);
|
|
252
|
+
};
|
|
253
|
+
const scheduleRetry = () => {
|
|
254
|
+
if (runtime.retryTimer || runtime.queue.length === 0) return;
|
|
255
|
+
runtime.retryTimer = setTimeout(() => {
|
|
256
|
+
runtime.retryTimer = null;
|
|
257
|
+
flush();
|
|
258
|
+
}, RETRY_MS);
|
|
259
|
+
};
|
|
260
|
+
const preferencesPath = () => {
|
|
261
|
+
const scope = runtime.options?.getScope?.() ?? {};
|
|
262
|
+
const params = new URLSearchParams();
|
|
263
|
+
if (scope.fulfillment_id) params.set("fulfillment_id", scope.fulfillment_id);
|
|
264
|
+
if (scope.store_id) params.set("store_id", scope.store_id);
|
|
265
|
+
if (scope.channel_id) params.set("channel_id", scope.channel_id);
|
|
266
|
+
if (scope.brand_id) params.set("brand_id", scope.brand_id);
|
|
267
|
+
const query = params.toString();
|
|
268
|
+
const path = "/api/storefront/v1/preferences";
|
|
269
|
+
return query ? `${path}?${query}` : path;
|
|
270
|
+
};
|
|
271
|
+
const fetchRemoteConfig = async () => {
|
|
272
|
+
const http = await httpOptions();
|
|
273
|
+
return (await storefrontJson(...[
|
|
274
|
+
preferencesPath(),
|
|
275
|
+
{ method: "GET" },
|
|
276
|
+
http
|
|
277
|
+
])).analytics;
|
|
278
|
+
};
|
|
279
|
+
let flushWait = null;
|
|
280
|
+
const flushOnce = async () => {
|
|
281
|
+
if (!runtime.options || runtime.queue.length === 0) return;
|
|
282
|
+
if (!await isOnline()) {
|
|
283
|
+
scheduleRetry();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
while (runtime.queue.length > 0) {
|
|
287
|
+
const chunk = runtime.queue.slice(0, 200);
|
|
288
|
+
try {
|
|
289
|
+
await withExponentialRetry(() => postChunk(chunk), { shouldRetry: isTransientError });
|
|
290
|
+
runtime.queue = runtime.queue.slice(chunk.length);
|
|
291
|
+
await persistQueue();
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (isPoisonChunk(error)) {
|
|
294
|
+
runtime.queue = runtime.queue.slice(chunk.length);
|
|
295
|
+
await persistQueue();
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
scheduleRetry();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
clearTimer("retryTimer");
|
|
303
|
+
};
|
|
304
|
+
const flush = async () => {
|
|
305
|
+
if (flushWait) return flushWait;
|
|
306
|
+
flushWait = flushOnce().finally(() => {
|
|
307
|
+
flushWait = null;
|
|
308
|
+
});
|
|
309
|
+
return flushWait;
|
|
310
|
+
};
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/uuid.ts
|
|
313
|
+
/** UUID v4 — works when `crypto.randomUUID` is missing (React Native / Hermes). */
|
|
314
|
+
const randomUUID = () => {
|
|
315
|
+
const c = globalThis.crypto;
|
|
316
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
317
|
+
if (typeof c?.getRandomValues !== "function") throw new Error("crypto.getRandomValues is required");
|
|
318
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
319
|
+
c.getRandomValues(bytes);
|
|
320
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
321
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
322
|
+
const toHex = (b) => b.toString(16).padStart(2, "0");
|
|
323
|
+
const hex = Array.from(bytes, toHex).join("");
|
|
324
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
325
|
+
};
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/session.ts
|
|
328
|
+
const SESSION_KEY = "firespark:analytics:session";
|
|
329
|
+
const createSession = (now) => {
|
|
330
|
+
return {
|
|
331
|
+
id: randomUUID(),
|
|
332
|
+
started_at: now,
|
|
333
|
+
last_event_at: now,
|
|
334
|
+
seen: []
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
const parseSession = (raw) => {
|
|
338
|
+
if (!raw) return null;
|
|
339
|
+
try {
|
|
340
|
+
const parsed = JSON.parse(raw);
|
|
341
|
+
if (!parsed?.id || typeof parsed.last_event_at !== "number") return null;
|
|
342
|
+
if (!Array.isArray(parsed.seen)) return null;
|
|
343
|
+
return parsed;
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const isSessionExpired = (session, now, timeoutMs) => {
|
|
349
|
+
return now - session.last_event_at > timeoutMs;
|
|
350
|
+
};
|
|
351
|
+
const touchSession = (session, now, timeoutMs) => {
|
|
352
|
+
if (!session || isSessionExpired(session, now, timeoutMs)) return createSession(now);
|
|
353
|
+
return {
|
|
354
|
+
...session,
|
|
355
|
+
last_event_at: now
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
const markSeen = (session, name) => {
|
|
359
|
+
if (!isFunnelEventName(name)) return session;
|
|
360
|
+
if (session.seen.includes(name)) return session;
|
|
361
|
+
return {
|
|
362
|
+
...session,
|
|
363
|
+
seen: [...session.seen, name]
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
const shouldSkipFunnelEvent = (session, name) => {
|
|
367
|
+
return isFunnelEventName(name) && session.seen.includes(name);
|
|
368
|
+
};
|
|
369
|
+
const shouldCarryMenuViewed = (prev, next) => {
|
|
370
|
+
if (!prev || prev.id === next.id) return false;
|
|
371
|
+
return prev.seen.includes("menu_viewed");
|
|
372
|
+
};
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/index.ts
|
|
375
|
+
const entityIdFromEvent = (event) => {
|
|
376
|
+
const props = event.properties;
|
|
377
|
+
if (!props) return void 0;
|
|
378
|
+
if ("product_id" in props && typeof props.product_id === "string") return props.product_id;
|
|
379
|
+
if ("category_id" in props && typeof props.category_id === "string") return props.category_id;
|
|
380
|
+
if ("order_id" in props && typeof props.order_id === "string") return props.order_id;
|
|
381
|
+
};
|
|
382
|
+
const armSessionTimer = () => {
|
|
383
|
+
clearTimer("sessionTimer");
|
|
384
|
+
if (!runtime.session) return;
|
|
385
|
+
const last = runtime.session.last_event_at;
|
|
386
|
+
const delay = runtime.sessionMs - (Date.now() - last);
|
|
387
|
+
runtime.sessionTimer = setTimeout(() => {
|
|
388
|
+
runtime.sessionTimer = null;
|
|
389
|
+
endSession();
|
|
390
|
+
}, Math.max(delay, 0));
|
|
391
|
+
};
|
|
392
|
+
const loadPersisted = async () => {
|
|
393
|
+
if (runtime.hydrated) return;
|
|
394
|
+
runtime.hydrated = true;
|
|
395
|
+
runtime.session = parseSession(await readStorage(SESSION_KEY));
|
|
396
|
+
runtime.queue = parseQueue(await readStorage(QUEUE_KEY));
|
|
397
|
+
runtime.interactions = parseInteractions(await readStorage(INTERACTIONS_KEY));
|
|
398
|
+
const rawConfig = await readStorage(CONFIG_KEY);
|
|
399
|
+
if (!rawConfig) return;
|
|
400
|
+
try {
|
|
401
|
+
const parsed = JSON.parse(rawConfig);
|
|
402
|
+
if (typeof parsed.session_ms === "number" && parsed.session_ms > 0) runtime.sessionMs = parsed.session_ms;
|
|
403
|
+
} catch {}
|
|
404
|
+
};
|
|
405
|
+
const refreshConfig = async () => {
|
|
406
|
+
const provided = runtime.options?.sessionMs;
|
|
407
|
+
if (typeof provided === "number" && provided > 0) {
|
|
408
|
+
runtime.sessionMs = provided;
|
|
409
|
+
await persistConfig(runtime.sessionMs);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
runtime.sessionMs = (await fetchRemoteConfig()).session_ms || 18e5;
|
|
414
|
+
await persistConfig(runtime.sessionMs);
|
|
415
|
+
} catch {
|
|
416
|
+
if (!runtime.sessionMs) runtime.sessionMs = DEFAULT_SESSION_MS;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const configure = async (options) => {
|
|
420
|
+
runtime.options = options;
|
|
421
|
+
runtime.context = buildEventContext(options.context);
|
|
422
|
+
await loadPersisted();
|
|
423
|
+
await refreshConfig();
|
|
424
|
+
armSessionTimer();
|
|
425
|
+
if (runtime.queue.length > 0) flush();
|
|
426
|
+
};
|
|
427
|
+
const toQueued = (event, now, sessionId, customer) => {
|
|
428
|
+
const scope = runtime.options?.getScope?.() ?? {};
|
|
429
|
+
const platform = runtime.options?.platform ?? "web";
|
|
430
|
+
const b1 = {
|
|
431
|
+
id: randomUUID(),
|
|
432
|
+
name: event.name,
|
|
433
|
+
session_id: sessionId
|
|
434
|
+
};
|
|
435
|
+
const b2 = { is_anonymous: customer.is_anonymous };
|
|
436
|
+
const b3 = { timestamp: new Date(now).toISOString() };
|
|
437
|
+
const b4 = {
|
|
438
|
+
store_id: scope.store_id,
|
|
439
|
+
channel_id: scope.channel_id
|
|
440
|
+
};
|
|
441
|
+
const b5 = {
|
|
442
|
+
brand_id: scope.brand_id,
|
|
443
|
+
fulfillment_id: scope.fulfillment_id
|
|
444
|
+
};
|
|
445
|
+
const b6 = { entity_id: entityIdFromEvent(event) };
|
|
446
|
+
const b7 = {
|
|
447
|
+
properties: event.properties,
|
|
448
|
+
platform
|
|
449
|
+
};
|
|
450
|
+
const b8 = { context: runtime.context ?? buildEventContext() };
|
|
451
|
+
return {
|
|
452
|
+
...b1,
|
|
453
|
+
...b2,
|
|
454
|
+
...b3,
|
|
455
|
+
...b4,
|
|
456
|
+
...b5,
|
|
457
|
+
...b6,
|
|
458
|
+
...b7,
|
|
459
|
+
...b8
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
const trackEvent = async (event) => {
|
|
463
|
+
if (!runtime.options) return;
|
|
464
|
+
const customer = runtime.options.getCustomer();
|
|
465
|
+
if (!customer) return;
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
const prev = runtime.session;
|
|
468
|
+
let session = touchSession(prev, now, runtime.sessionMs);
|
|
469
|
+
if (isInteractionEventName(event.name)) {
|
|
470
|
+
const entityId = entityIdFromEvent(event);
|
|
471
|
+
const bound = bindInteractions(runtime.interactions, session.id);
|
|
472
|
+
if (!entityId || shouldSkipInteraction(bound, event.name, entityId)) {
|
|
473
|
+
runtime.session = {
|
|
474
|
+
...session,
|
|
475
|
+
last_event_at: now
|
|
476
|
+
};
|
|
477
|
+
runtime.interactions = bound;
|
|
478
|
+
await persistSession();
|
|
479
|
+
await persistInteractions();
|
|
480
|
+
armSessionTimer();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
runtime.interactions = markInteraction(bound, event.name, entityId);
|
|
484
|
+
runtime.session = session;
|
|
485
|
+
const queued = toQueued(event, now, session.id, customer);
|
|
486
|
+
runtime.queue = enqueueEvent(runtime.queue, queued, now, getLimits());
|
|
487
|
+
await persistSession();
|
|
488
|
+
await persistInteractions();
|
|
489
|
+
await persistQueue();
|
|
490
|
+
armSessionTimer();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (shouldCarryMenuViewed(prev, session) && event.name !== "menu_viewed") {
|
|
494
|
+
session = markSeen(session, "menu_viewed");
|
|
495
|
+
const p1 = [
|
|
496
|
+
{ name: "menu_viewed" },
|
|
497
|
+
now,
|
|
498
|
+
session.id,
|
|
499
|
+
customer
|
|
500
|
+
];
|
|
501
|
+
const carried = toQueued(...p1);
|
|
502
|
+
runtime.queue = enqueueEvent(runtime.queue, carried, now, getLimits());
|
|
503
|
+
}
|
|
504
|
+
if (shouldSkipFunnelEvent(session, event.name)) {
|
|
505
|
+
runtime.session = {
|
|
506
|
+
...session,
|
|
507
|
+
last_event_at: now
|
|
508
|
+
};
|
|
509
|
+
await persistSession();
|
|
510
|
+
await persistQueue();
|
|
511
|
+
armSessionTimer();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
session = markSeen(session, event.name);
|
|
515
|
+
runtime.session = session;
|
|
516
|
+
const queued = toQueued(event, now, session.id, customer);
|
|
517
|
+
runtime.queue = enqueueEvent(runtime.queue, queued, now, getLimits());
|
|
518
|
+
await persistSession();
|
|
519
|
+
await persistQueue();
|
|
520
|
+
if (event.name === "order_placed") {
|
|
521
|
+
await endSession();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
armSessionTimer();
|
|
525
|
+
};
|
|
526
|
+
let trackChain = Promise.resolve();
|
|
527
|
+
const track = (event) => {
|
|
528
|
+
const run = () => trackEvent(event);
|
|
529
|
+
const next = trackChain.then(run, run);
|
|
530
|
+
const ok = () => void 0;
|
|
531
|
+
trackChain = next.then(ok, ok);
|
|
532
|
+
return next;
|
|
533
|
+
};
|
|
534
|
+
const endSession = async () => {
|
|
535
|
+
clearTimer("sessionTimer");
|
|
536
|
+
runtime.session = null;
|
|
537
|
+
await persistSession();
|
|
538
|
+
await flush();
|
|
539
|
+
};
|
|
540
|
+
const reset = async () => {
|
|
541
|
+
const storage = runtime.options?.storage;
|
|
542
|
+
resetRuntime();
|
|
543
|
+
if (!storage) return;
|
|
544
|
+
await storage.removeItem(SESSION_KEY);
|
|
545
|
+
await storage.removeItem(QUEUE_KEY);
|
|
546
|
+
await storage.removeItem(CONFIG_KEY);
|
|
547
|
+
await storage.removeItem(INTERACTIONS_KEY);
|
|
548
|
+
};
|
|
549
|
+
const analytics = {
|
|
550
|
+
configure,
|
|
551
|
+
track,
|
|
552
|
+
flush,
|
|
553
|
+
endSession,
|
|
554
|
+
reset
|
|
555
|
+
};
|
|
556
|
+
//#endregion
|
|
557
|
+
export { FUNNEL_EVENTS, INTERACTION_EVENTS, analytics, configure, endSession, isFunnelEventName, isInteractionEventName, reset, track };
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fire-spark/analytics",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Fire Spark storefront analytics SDK",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"development": "./src/index.ts",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@fire-spark/core": "0.0.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tsdown": "^0.22.14",
|
|
25
|
+
"typescript": "^7.0.2",
|
|
26
|
+
"vitest": "^2.1.8"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"tsdown": {
|
|
32
|
+
"entry": [
|
|
33
|
+
"src/index.ts"
|
|
34
|
+
],
|
|
35
|
+
"format": [
|
|
36
|
+
"esm"
|
|
37
|
+
],
|
|
38
|
+
"platform": "neutral",
|
|
39
|
+
"dts": true,
|
|
40
|
+
"clean": true,
|
|
41
|
+
"deps": {
|
|
42
|
+
"neverBundle": [
|
|
43
|
+
"@fire-spark/core"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsdown",
|
|
49
|
+
"dev": "tsdown --watch",
|
|
50
|
+
"check-types": "tsc --noEmit",
|
|
51
|
+
"test:utils": "vitest run",
|
|
52
|
+
"lint": "oxlint ./src --max-warnings=0",
|
|
53
|
+
"format": "oxfmt ./src",
|
|
54
|
+
"check-format": "oxfmt --check ./src"
|
|
55
|
+
}
|
|
56
|
+
}
|