@reservamos/browser-analytics 1.1.1 → 1.1.2
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/CHANGELOG.md +5 -0
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.esm.js +61 -25
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +1 -1
- package/src/init.ts +1 -1
- package/src/services/geolocation.test.ts +109 -23
- package/src/services/geolocation.ts +90 -24
- package/src/track.ts +6 -2
package/package.json
CHANGED
package/src/init.ts
CHANGED
|
@@ -49,7 +49,7 @@ async function init(config: InitConfig) {
|
|
|
49
49
|
console.error('Error initializing identification service:', error);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
geolocationService.init();
|
|
52
|
+
await geolocationService.init();
|
|
53
53
|
|
|
54
54
|
configService.setEnvironment(isSandbox ? 'sandbox' : 'prod');
|
|
55
55
|
coreApi.setConfig(configService.getCoreAPIConfig());
|
|
@@ -101,11 +101,13 @@ describe('geolocationService', () => {
|
|
|
101
101
|
'Geolocation Updated At': updatedAt,
|
|
102
102
|
'Geolocation Status': 'granted',
|
|
103
103
|
'Geolocation Accuracy': 12,
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
latitude: 19.4326,
|
|
105
|
+
longitude: -99.1332,
|
|
106
|
+
geolocation_source: 'browser',
|
|
106
107
|
};
|
|
107
108
|
|
|
108
109
|
expect(coordinates).toEqual({ lat: 19.4326, long: -99.1332 });
|
|
110
|
+
expect(geolocationService.getGeolocationSource()).toBe('browser');
|
|
109
111
|
expect(trackCustomEventMock).toHaveBeenCalledWith(
|
|
110
112
|
'Geolocation Requested',
|
|
111
113
|
expectedProperties,
|
|
@@ -113,8 +115,9 @@ describe('geolocationService', () => {
|
|
|
113
115
|
expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
|
|
114
116
|
'Geolocation Updated At': updatedAt,
|
|
115
117
|
'Geolocation Status': 'granted',
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
latitude: 19.4326,
|
|
119
|
+
longitude: -99.1332,
|
|
120
|
+
geolocation_source: 'browser',
|
|
118
121
|
});
|
|
119
122
|
|
|
120
123
|
vi.useRealTimers();
|
|
@@ -142,6 +145,7 @@ describe('geolocationService', () => {
|
|
|
142
145
|
};
|
|
143
146
|
|
|
144
147
|
expect(coordinates).toBeNull();
|
|
148
|
+
expect(geolocationService.getGeolocationSource()).toBeNull();
|
|
145
149
|
expect(trackCustomEventMock).toHaveBeenCalledWith(
|
|
146
150
|
'Geolocation Requested',
|
|
147
151
|
expectedProperties,
|
|
@@ -217,8 +221,9 @@ describe('geolocationService', () => {
|
|
|
217
221
|
'Geolocation Updated At': updatedAt,
|
|
218
222
|
'Geolocation Status': 'skipped_cache',
|
|
219
223
|
'Geolocation Accuracy': 25,
|
|
220
|
-
|
|
221
|
-
|
|
224
|
+
latitude: 19.4326,
|
|
225
|
+
longitude: -99.1332,
|
|
226
|
+
geolocation_source: 'browser',
|
|
222
227
|
};
|
|
223
228
|
|
|
224
229
|
expect(coordinates).toEqual({ lat: 19.4326, long: -99.1332 });
|
|
@@ -229,33 +234,114 @@ describe('geolocationService', () => {
|
|
|
229
234
|
expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
|
|
230
235
|
'Geolocation Updated At': updatedAt,
|
|
231
236
|
'Geolocation Status': 'skipped_cache',
|
|
232
|
-
|
|
233
|
-
|
|
237
|
+
latitude: 19.4326,
|
|
238
|
+
longitude: -99.1332,
|
|
239
|
+
geolocation_source: 'browser',
|
|
234
240
|
});
|
|
235
241
|
});
|
|
236
242
|
|
|
237
|
-
it('
|
|
238
|
-
const
|
|
239
|
-
|
|
243
|
+
it('repairs legacy storage by clearing permission timestamp when coordinates are missing', async () => {
|
|
244
|
+
const updatedAt = '2026-05-07T21:19:00.000Z';
|
|
245
|
+
vi.useFakeTimers();
|
|
246
|
+
vi.setSystemTime(new Date(updatedAt));
|
|
240
247
|
|
|
241
|
-
localStorage.setItem('geolocationPermissionTimestamp',
|
|
248
|
+
localStorage.setItem('geolocationPermissionTimestamp', Date.now().toString());
|
|
242
249
|
localStorage.setItem('geolocationCache', '{ invalid json');
|
|
243
250
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
251
|
+
mockGeolocationSuccess({
|
|
252
|
+
coords: { latitude: 19.4326, longitude: -99.1332, accuracy: 15 },
|
|
253
|
+
} as GeolocationPosition);
|
|
254
|
+
|
|
255
|
+
const geolocationService = (await import('./geolocation')).default;
|
|
256
|
+
await geolocationService.init();
|
|
257
|
+
|
|
258
|
+
expect(localStorage.getItem('geolocationPermissionTimestamp')).toBe(
|
|
259
|
+
Date.parse(updatedAt).toString(),
|
|
260
|
+
);
|
|
261
|
+
expect(JSON.parse(localStorage.getItem('geolocationCache') ?? '')).toEqual({
|
|
262
|
+
lat: 19.4326,
|
|
263
|
+
long: -99.1332,
|
|
264
|
+
updatedAt,
|
|
265
|
+
accuracy: 15,
|
|
250
266
|
});
|
|
251
267
|
|
|
268
|
+
vi.useRealTimers();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('requests fresh geolocation when the permission cache is valid but coordinates are missing', async () => {
|
|
272
|
+
const updatedAt = '2026-05-07T21:19:00.000Z';
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
vi.setSystemTime(new Date(updatedAt));
|
|
275
|
+
|
|
276
|
+
localStorage.setItem('geolocationPermissionTimestamp', Date.now().toString());
|
|
277
|
+
localStorage.setItem('geolocationCache', '{ invalid json');
|
|
278
|
+
|
|
279
|
+
mockGeolocationSuccess({
|
|
280
|
+
coords: { latitude: 19.4326, longitude: -99.1332, accuracy: 15 },
|
|
281
|
+
} as GeolocationPosition);
|
|
282
|
+
|
|
252
283
|
const geolocationService = (await import('./geolocation')).default;
|
|
253
284
|
const coordinates = await geolocationService.init();
|
|
254
285
|
|
|
255
|
-
expect(coordinates).
|
|
256
|
-
expect(
|
|
257
|
-
'Geolocation
|
|
258
|
-
|
|
259
|
-
|
|
286
|
+
expect(coordinates).toEqual({ lat: 19.4326, long: -99.1332 });
|
|
287
|
+
expect(trackCustomEventMock).toHaveBeenCalledWith(
|
|
288
|
+
'Geolocation Requested',
|
|
289
|
+
{
|
|
290
|
+
'Geolocation Updated At': updatedAt,
|
|
291
|
+
'Geolocation Status': 'granted',
|
|
292
|
+
'Geolocation Accuracy': 15,
|
|
293
|
+
latitude: 19.4326,
|
|
294
|
+
longitude: -99.1332,
|
|
295
|
+
geolocation_source: 'browser',
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
vi.useRealTimers();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('requests fresh geolocation when the permission cache is older than one hour', async () => {
|
|
303
|
+
const updatedAt = '2026-05-07T21:19:00.000Z';
|
|
304
|
+
vi.useFakeTimers();
|
|
305
|
+
vi.setSystemTime(new Date(updatedAt));
|
|
306
|
+
|
|
307
|
+
const expiredPermissionTimestamp = (
|
|
308
|
+
Date.parse(updatedAt) - 60 * 60 * 1000 - 1
|
|
309
|
+
).toString();
|
|
310
|
+
|
|
311
|
+
localStorage.setItem(
|
|
312
|
+
'geolocationPermissionTimestamp',
|
|
313
|
+
expiredPermissionTimestamp,
|
|
314
|
+
);
|
|
315
|
+
localStorage.setItem(
|
|
316
|
+
'geolocationCache',
|
|
317
|
+
JSON.stringify({
|
|
318
|
+
lat: 19.4326,
|
|
319
|
+
long: -99.1332,
|
|
320
|
+
updatedAt: '2026-05-07T20:00:00.000Z',
|
|
321
|
+
accuracy: 25,
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
mockGeolocationSuccess({
|
|
326
|
+
coords: { latitude: 20.9674, longitude: -89.5926, accuracy: 8 },
|
|
327
|
+
} as GeolocationPosition);
|
|
328
|
+
|
|
329
|
+
const geolocationService = (await import('./geolocation')).default;
|
|
330
|
+
const coordinates = await geolocationService.init();
|
|
331
|
+
|
|
332
|
+
expect(coordinates).toEqual({ lat: 20.9674, long: -89.5926 });
|
|
333
|
+
expect(trackCustomEventMock).toHaveBeenCalledWith(
|
|
334
|
+
'Geolocation Requested',
|
|
335
|
+
{
|
|
336
|
+
'Geolocation Updated At': updatedAt,
|
|
337
|
+
'Geolocation Status': 'granted',
|
|
338
|
+
'Geolocation Accuracy': 8,
|
|
339
|
+
latitude: 20.9674,
|
|
340
|
+
longitude: -89.5926,
|
|
341
|
+
geolocation_source: 'browser',
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
vi.useRealTimers();
|
|
260
346
|
});
|
|
261
347
|
});
|
|
@@ -14,11 +14,20 @@ interface GeolocationCache {
|
|
|
14
14
|
accuracy?: number;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Origin of the coordinates attached to geolocation events and profiles.
|
|
19
|
+
* - `browser` — Coordinates from the Geolocation API or a prior browser session cache.
|
|
20
|
+
*/
|
|
21
|
+
type GeolocationSource = 'browser';
|
|
22
|
+
|
|
17
23
|
interface GeolocationTrackingData {
|
|
18
24
|
status: GeolocationStatus;
|
|
19
25
|
updatedAt: string;
|
|
26
|
+
lat?: number;
|
|
27
|
+
long?: number;
|
|
20
28
|
accuracy?: number;
|
|
21
29
|
errorCode?: number;
|
|
30
|
+
source?: GeolocationSource;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
/**
|
|
@@ -28,16 +37,17 @@ interface GeolocationTrackingData {
|
|
|
28
37
|
* - `granted` — The browser returned fresh coordinates successfully.
|
|
29
38
|
* - `denied` — The user blocked location access (PERMISSION_DENIED).
|
|
30
39
|
* - `error` — The request failed for another reason (timeout, position unavailable, or geolocation unsupported).
|
|
31
|
-
* - `skipped_cache` — Permission was granted within the last
|
|
40
|
+
* - `skipped_cache` — Permission was granted within the last hour; cached coordinates were reused without a new browser request.
|
|
32
41
|
*/
|
|
33
42
|
type GeolocationStatus = 'granted' | 'denied' | 'error' | 'skipped_cache';
|
|
34
43
|
|
|
35
44
|
let currentCoordinates: Coordinates | null = null;
|
|
45
|
+
let currentSource: GeolocationSource | null = null;
|
|
36
46
|
|
|
37
47
|
const GEOLOCATION_PERMISSION_KEY = 'geolocationPermissionTimestamp';
|
|
38
48
|
const GEOLOCATION_CACHE_KEY = 'geolocationCache';
|
|
39
49
|
const GEOLOCATION_TRACK_EVENT = 'Geolocation Requested';
|
|
40
|
-
const
|
|
50
|
+
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
|
|
41
51
|
/** Max wait for a high-accuracy position fix before the browser reports TIMEOUT. */
|
|
42
52
|
const GEOLOCATION_REQUEST_TIMEOUT_MS = 10_000;
|
|
43
53
|
const GEOLOCATION_OPTIONS: PositionOptions = {
|
|
@@ -72,6 +82,18 @@ function safeSetItem(key: string, value: string): void {
|
|
|
72
82
|
}
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Removes a value from localStorage, ignoring failures when storage is unavailable.
|
|
87
|
+
* @param {string} key - Storage key to remove.
|
|
88
|
+
*/
|
|
89
|
+
function safeRemoveItem(key: string): void {
|
|
90
|
+
try {
|
|
91
|
+
localStorage.removeItem(key);
|
|
92
|
+
} catch {
|
|
93
|
+
// Storage may be blocked (private mode, quota exceeded, etc.)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
/**
|
|
76
98
|
* Converts a Unix timestamp to an ISO 8601 string.
|
|
77
99
|
* @param {number} timestamp - Unix timestamp in milliseconds.
|
|
@@ -150,6 +172,7 @@ function restoreCoordinatesFromStorage(): void {
|
|
|
150
172
|
|
|
151
173
|
if (cache) {
|
|
152
174
|
currentCoordinates = { lat: cache.lat, long: cache.long };
|
|
175
|
+
currentSource = 'browser';
|
|
153
176
|
}
|
|
154
177
|
}
|
|
155
178
|
|
|
@@ -163,7 +186,7 @@ function isPermissionTimestampValid(
|
|
|
163
186
|
): boolean {
|
|
164
187
|
if (!lastPermissionTimestamp) return false;
|
|
165
188
|
const now = Date.now();
|
|
166
|
-
return now - parseInt(lastPermissionTimestamp, 10) <
|
|
189
|
+
return now - parseInt(lastPermissionTimestamp, 10) < ONE_HOUR_IN_MS;
|
|
167
190
|
}
|
|
168
191
|
|
|
169
192
|
/**
|
|
@@ -174,6 +197,21 @@ function updatePermissionTimestamp(timestamp: number): void {
|
|
|
174
197
|
safeSetItem(GEOLOCATION_PERMISSION_KEY, timestamp.toString());
|
|
175
198
|
}
|
|
176
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Repairs inconsistent localStorage left by older SDK versions that persisted a
|
|
202
|
+
* permission timestamp even when coordinates were never cached (e.g. after deny or error).
|
|
203
|
+
* Runs automatically on init so users do not need to clear storage after upgrading.
|
|
204
|
+
*/
|
|
205
|
+
function repairLegacyGeolocationStorage(): void {
|
|
206
|
+
if (!safeGetItem(GEOLOCATION_PERMISSION_KEY)) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!loadCoordinatesFromStorage()) {
|
|
211
|
+
safeRemoveItem(GEOLOCATION_PERMISSION_KEY);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
177
215
|
/**
|
|
178
216
|
* Maps a GeolocationPositionError to a geolocation status.
|
|
179
217
|
* Permission denial maps to `denied`; timeout and position unavailable map to `error`.
|
|
@@ -207,10 +245,17 @@ function buildGeolocationEventProperties(
|
|
|
207
245
|
properties['Geolocation Error Code'] = data.errorCode;
|
|
208
246
|
}
|
|
209
247
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
248
|
+
if (
|
|
249
|
+
(data.status === 'granted' || data.status === 'skipped_cache') &&
|
|
250
|
+
data.lat !== undefined &&
|
|
251
|
+
data.long !== undefined
|
|
252
|
+
) {
|
|
253
|
+
properties.latitude = data.lat;
|
|
254
|
+
properties.longitude = data.long;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (data.source !== undefined) {
|
|
258
|
+
properties.geolocation_source = data.source;
|
|
214
259
|
}
|
|
215
260
|
|
|
216
261
|
return properties;
|
|
@@ -230,10 +275,17 @@ function buildGeolocationProfileProperties(
|
|
|
230
275
|
'Geolocation Status': data.status,
|
|
231
276
|
};
|
|
232
277
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
278
|
+
if (
|
|
279
|
+
(data.status === 'granted' || data.status === 'skipped_cache') &&
|
|
280
|
+
data.lat !== undefined &&
|
|
281
|
+
data.long !== undefined
|
|
282
|
+
) {
|
|
283
|
+
properties.latitude = data.lat;
|
|
284
|
+
properties.longitude = data.long;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (data.source !== undefined) {
|
|
288
|
+
properties.geolocation_source = data.source;
|
|
237
289
|
}
|
|
238
290
|
|
|
239
291
|
return properties;
|
|
@@ -260,6 +312,7 @@ function syncGeolocationToMixpanel(data: GeolocationTrackingData): void {
|
|
|
260
312
|
* @returns {Promise<Coordinates | null>} Cached or fresh coordinates, or null on failure.
|
|
261
313
|
*/
|
|
262
314
|
function init(): Promise<Coordinates | null> {
|
|
315
|
+
repairLegacyGeolocationStorage();
|
|
263
316
|
restoreCoordinatesFromStorage();
|
|
264
317
|
|
|
265
318
|
return new Promise((resolve) => {
|
|
@@ -267,18 +320,19 @@ function init(): Promise<Coordinates | null> {
|
|
|
267
320
|
|
|
268
321
|
if (isPermissionTimestampValid(lastPermissionTimestamp)) {
|
|
269
322
|
const cache = loadCoordinatesFromStorage();
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
323
|
+
|
|
324
|
+
if (cache) {
|
|
325
|
+
syncGeolocationToMixpanel({
|
|
326
|
+
status: 'skipped_cache',
|
|
327
|
+
updatedAt: cache.updatedAt,
|
|
328
|
+
lat: cache.lat,
|
|
329
|
+
long: cache.long,
|
|
330
|
+
accuracy: cache.accuracy,
|
|
331
|
+
source: 'browser',
|
|
332
|
+
});
|
|
333
|
+
resolve(currentCoordinates);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
282
336
|
}
|
|
283
337
|
|
|
284
338
|
const now = Date.now();
|
|
@@ -294,19 +348,22 @@ function init(): Promise<Coordinates | null> {
|
|
|
294
348
|
(geolocation) => {
|
|
295
349
|
const { latitude: lat, longitude: long, accuracy } = geolocation.coords;
|
|
296
350
|
currentCoordinates = { lat, long };
|
|
351
|
+
currentSource = 'browser';
|
|
297
352
|
saveCoordinatesToStorage(currentCoordinates, updatedAt, accuracy);
|
|
298
353
|
updatePermissionTimestamp(now);
|
|
299
354
|
|
|
300
355
|
syncGeolocationToMixpanel({
|
|
301
356
|
status: 'granted',
|
|
302
357
|
updatedAt,
|
|
358
|
+
lat,
|
|
359
|
+
long,
|
|
303
360
|
accuracy,
|
|
361
|
+
source: 'browser',
|
|
304
362
|
});
|
|
305
363
|
resolve(currentCoordinates);
|
|
306
364
|
},
|
|
307
365
|
(error) => {
|
|
308
366
|
const status = getGeolocationStatus(error);
|
|
309
|
-
updatePermissionTimestamp(now);
|
|
310
367
|
|
|
311
368
|
syncGeolocationToMixpanel({
|
|
312
369
|
status,
|
|
@@ -328,6 +385,14 @@ function getCoordinates(): Coordinates | null {
|
|
|
328
385
|
return currentCoordinates;
|
|
329
386
|
}
|
|
330
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Returns the source of the currently cached coordinates, or null when unavailable.
|
|
390
|
+
* @returns {GeolocationSource | null} The geolocation source or null if no coordinates are cached.
|
|
391
|
+
*/
|
|
392
|
+
function getGeolocationSource(): GeolocationSource | null {
|
|
393
|
+
return currentSource;
|
|
394
|
+
}
|
|
395
|
+
|
|
331
396
|
/**
|
|
332
397
|
* Geolocation service for tracking user location with caching support.
|
|
333
398
|
* Handles permission requests, coordinate retrieval, and analytics sync to Mixpanel.
|
|
@@ -335,6 +400,7 @@ function getCoordinates(): Coordinates | null {
|
|
|
335
400
|
const geolocationService = {
|
|
336
401
|
init,
|
|
337
402
|
getCoordinates,
|
|
403
|
+
getGeolocationSource,
|
|
338
404
|
};
|
|
339
405
|
|
|
340
406
|
export default geolocationService;
|
package/src/track.ts
CHANGED
|
@@ -125,9 +125,13 @@ export async function trackEvent(
|
|
|
125
125
|
const fingerprint = await fingerprintService.getFingerprint(cacheOnly);
|
|
126
126
|
const defaultCoordinates: Record<string, string | number> = {};
|
|
127
127
|
const coordinates = geolocationService.getCoordinates();
|
|
128
|
+
const geolocationSource = geolocationService.getGeolocationSource();
|
|
128
129
|
if (coordinates) {
|
|
129
|
-
defaultCoordinates
|
|
130
|
-
defaultCoordinates
|
|
130
|
+
defaultCoordinates.latitude = coordinates.lat;
|
|
131
|
+
defaultCoordinates.longitude = coordinates.long;
|
|
132
|
+
}
|
|
133
|
+
if (geolocationSource) {
|
|
134
|
+
defaultCoordinates.geolocation_source = geolocationSource;
|
|
131
135
|
}
|
|
132
136
|
const defaultProperties = {
|
|
133
137
|
'User Fingerprint': fingerprint,
|