@grainql/analytics-web 3.2.0 → 3.2.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.
@@ -65,8 +65,12 @@ class HeatmapTrackingManager {
65
65
  // Snapshot capture state
66
66
  this.snapshotCaptured = false;
67
67
  this.snapshotEnabled = false;
68
+ // Device type detection
69
+ this.deviceType = 'desktop';
68
70
  this.tracker = tracker;
69
71
  this.options = { ...DEFAULT_OPTIONS, ...options };
72
+ // Detect device type on initialization
73
+ this.deviceType = this.detectDeviceType();
70
74
  // Initialize attention quality manager
71
75
  this.attentionQuality = new attention_quality_1.AttentionQualityManager(tracker.getActivityDetector(), {
72
76
  maxSectionDuration: 9000, // 9 seconds per viewport section
@@ -109,6 +113,34 @@ class HeatmapTrackingManager {
109
113
  /**
110
114
  * Check remote config for snapshot capture enablement
111
115
  */
116
+ /**
117
+ * Detect device type based on viewport width and user agent
118
+ * Mobile: width < 768px OR mobile user agent
119
+ * Desktop: width >= 768px AND non-mobile user agent
120
+ */
121
+ detectDeviceType() {
122
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
123
+ return 'desktop'; // SSR default
124
+ }
125
+ // Check viewport width
126
+ const width = window.innerWidth || window.screen?.width || 0;
127
+ const isMobileWidth = width < 768;
128
+ // Check user agent for mobile indicators
129
+ const userAgent = navigator.userAgent.toLowerCase();
130
+ const mobileKeywords = [
131
+ 'mobile',
132
+ 'android',
133
+ 'iphone',
134
+ 'ipad',
135
+ 'ipod',
136
+ 'blackberry',
137
+ 'windows phone',
138
+ 'webos',
139
+ ];
140
+ const isMobileUserAgent = mobileKeywords.some((keyword) => userAgent.includes(keyword));
141
+ // Device is mobile if either width OR user agent indicates mobile
142
+ return isMobileWidth || isMobileUserAgent ? 'mobile' : 'desktop';
143
+ }
112
144
  /**
113
145
  * Normalize URL by removing query params and hash, and stripping www prefix
114
146
  * This ensures heatmap data is aggregated by page, not by URL variations
@@ -147,6 +179,12 @@ class HeatmapTrackingManager {
147
179
  async captureSnapshot() {
148
180
  if (this.snapshotCaptured || !this.snapshotEnabled)
149
181
  return;
182
+ // Check daily snapshot limits before capturing
183
+ if (!this.canUploadSnapshot()) {
184
+ this.log('Snapshot upload limit reached or URL already captured today');
185
+ this.snapshotCaptured = true; // Prevent retry
186
+ return;
187
+ }
150
188
  try {
151
189
  this.log('Capturing DOM snapshot...');
152
190
  // Dynamically import rrweb-snapshot (only if enabled)
@@ -205,7 +243,8 @@ class HeatmapTrackingManager {
205
243
  sessionId,
206
244
  pageUrl,
207
245
  snapshot: JSON.stringify(snapshot),
208
- timestamp: Date.now()
246
+ timestamp: Date.now(),
247
+ deviceType: this.deviceType
209
248
  })
210
249
  });
211
250
  if (!response.ok) {
@@ -213,11 +252,97 @@ class HeatmapTrackingManager {
213
252
  }
214
253
  const result = await response.json();
215
254
  this.log('Snapshot uploaded successfully:', result);
255
+ // Record successful upload
256
+ this.recordSnapshotUpload(pageUrl);
216
257
  }
217
258
  catch (error) {
218
259
  this.log('Failed to upload snapshot:', error);
219
260
  }
220
261
  }
262
+ /**
263
+ * Check if snapshot can be uploaded based on daily limits
264
+ * - Max 5 snapshots per user per day
265
+ * - Same URL (without query params) can't be uploaded twice in same day
266
+ */
267
+ canUploadSnapshot() {
268
+ if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
269
+ return true; // Allow in SSR/non-browser environments
270
+ }
271
+ try {
272
+ const pageUrl = this.normalizeUrl(window.location.href);
273
+ const urlWithoutQuery = this.stripQueryParams(pageUrl);
274
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
275
+ const storageKey = '_grain_snapshots';
276
+ // Get existing snapshot records
277
+ const stored = localStorage.getItem(storageKey);
278
+ let snapshots = stored ? JSON.parse(stored) : [];
279
+ // Clean up old entries (older than 2 days)
280
+ const twoDaysAgo = Date.now() - (2 * 24 * 60 * 60 * 1000);
281
+ snapshots = snapshots.filter(s => s.timestamp > twoDaysAgo);
282
+ // Get today's snapshots
283
+ const todaySnapshots = snapshots.filter(s => s.date === today);
284
+ // Check daily limit (5 per day)
285
+ if (todaySnapshots.length >= 5) {
286
+ this.log('Daily snapshot limit reached (5/5)');
287
+ return false;
288
+ }
289
+ // Check if this URL (without query params) was already uploaded today
290
+ const urlAlreadyUploaded = todaySnapshots.some(s => this.stripQueryParams(s.url) === urlWithoutQuery);
291
+ if (urlAlreadyUploaded) {
292
+ this.log(`Snapshot for ${urlWithoutQuery} already uploaded today`);
293
+ return false;
294
+ }
295
+ this.log(`Snapshot upload allowed (${todaySnapshots.length}/5 today)`);
296
+ return true;
297
+ }
298
+ catch (error) {
299
+ this.log('Error checking snapshot limits:', error);
300
+ return true; // Allow on error to not break functionality
301
+ }
302
+ }
303
+ /**
304
+ * Record a successful snapshot upload
305
+ */
306
+ recordSnapshotUpload(pageUrl) {
307
+ if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
308
+ return;
309
+ }
310
+ try {
311
+ const today = new Date().toISOString().split('T')[0];
312
+ const storageKey = '_grain_snapshots';
313
+ // Get existing records
314
+ const stored = localStorage.getItem(storageKey);
315
+ let snapshots = stored ? JSON.parse(stored) : [];
316
+ // Add new record
317
+ snapshots.push({
318
+ url: pageUrl,
319
+ date: today,
320
+ timestamp: Date.now()
321
+ });
322
+ // Clean up old entries (older than 2 days)
323
+ const twoDaysAgo = Date.now() - (2 * 24 * 60 * 60 * 1000);
324
+ snapshots = snapshots.filter(s => s.timestamp > twoDaysAgo);
325
+ // Save back to localStorage
326
+ localStorage.setItem(storageKey, JSON.stringify(snapshots));
327
+ this.log(`Snapshot upload recorded: ${snapshots.filter(s => s.date === today).length}/5 today`);
328
+ }
329
+ catch (error) {
330
+ this.log('Error recording snapshot upload:', error);
331
+ }
332
+ }
333
+ /**
334
+ * Strip query parameters from URL for comparison
335
+ */
336
+ stripQueryParams(url) {
337
+ try {
338
+ const urlObj = new URL(url);
339
+ return `${urlObj.origin}${urlObj.pathname}`;
340
+ }
341
+ catch {
342
+ // If URL parsing fails, remove everything after ?
343
+ return url.split('?')[0];
344
+ }
345
+ }
221
346
  /**
222
347
  * Get API URL from tracker configuration
223
348
  */
@@ -349,6 +474,7 @@ class HeatmapTrackingManager {
349
474
  exitTimestamp: currentTime,
350
475
  pageHeight,
351
476
  viewportHeight,
477
+ deviceType: this.deviceType,
352
478
  };
353
479
  // Send immediately using beacon to ensure delivery
354
480
  this.tracker.trackSystemEvent('_grain_heatmap_scroll', {
@@ -360,6 +486,7 @@ class HeatmapTrackingManager {
360
486
  exit_timestamp: scrollData.exitTimestamp,
361
487
  page_height: scrollData.pageHeight,
362
488
  viewport_height: scrollData.viewportHeight,
489
+ device_type: scrollData.deviceType,
363
490
  is_split: true, // Flag to indicate periodic tracking, not final exit
364
491
  }, { flush: true });
365
492
  // Update attention quality duration tracker
@@ -390,6 +517,7 @@ class HeatmapTrackingManager {
390
517
  exitTimestamp: currentTime,
391
518
  pageHeight: document.documentElement.scrollHeight,
392
519
  viewportHeight: window.innerHeight,
520
+ deviceType: this.deviceType,
393
521
  };
394
522
  this.pendingScrolls.push(scrollData);
395
523
  }
@@ -448,6 +576,7 @@ class HeatmapTrackingManager {
448
576
  elementTag,
449
577
  elementText: elementText || undefined,
450
578
  timestamp: Date.now(),
579
+ deviceType: this.deviceType,
451
580
  };
452
581
  // Check if this is a navigation link
453
582
  const isNavigationLink = element instanceof HTMLAnchorElement && element.href;
@@ -466,6 +595,7 @@ class HeatmapTrackingManager {
466
595
  element_tag: clickData.elementTag,
467
596
  element_text: clickData.elementText,
468
597
  timestamp: clickData.timestamp,
598
+ device_type: clickData.deviceType,
469
599
  }, { flush: true });
470
600
  }
471
601
  else {
@@ -510,6 +640,7 @@ class HeatmapTrackingManager {
510
640
  exitTimestamp: currentTime,
511
641
  pageHeight,
512
642
  viewportHeight,
643
+ deviceType: this.deviceType,
513
644
  };
514
645
  this.pendingScrolls.push(scrollData);
515
646
  }
@@ -645,6 +776,7 @@ class HeatmapTrackingManager {
645
776
  element_tag: clickData.elementTag,
646
777
  element_text: clickData.elementText,
647
778
  timestamp: clickData.timestamp,
779
+ device_type: clickData.deviceType,
648
780
  });
649
781
  }
650
782
  this.pendingClicks = [];
@@ -661,6 +793,7 @@ class HeatmapTrackingManager {
661
793
  exit_timestamp: scrollData.exitTimestamp,
662
794
  page_height: scrollData.pageHeight,
663
795
  viewport_height: scrollData.viewportHeight,
796
+ device_type: scrollData.deviceType,
664
797
  });
665
798
  }
666
799
  this.pendingScrolls = [];
@@ -693,6 +826,7 @@ class HeatmapTrackingManager {
693
826
  element_tag: clickData.elementTag,
694
827
  element_text: clickData.elementText,
695
828
  timestamp: clickData.timestamp,
829
+ device_type: clickData.deviceType,
696
830
  }, { flush: true });
697
831
  }
698
832
  this.pendingClicks = [];
@@ -709,6 +843,7 @@ class HeatmapTrackingManager {
709
843
  exit_timestamp: scrollData.exitTimestamp,
710
844
  page_height: scrollData.pageHeight,
711
845
  viewport_height: scrollData.viewportHeight,
846
+ device_type: scrollData.deviceType,
712
847
  }, { flush: true });
713
848
  }
714
849
  this.pendingScrolls = [];
@@ -1,4 +1,4 @@
1
- /* Grain Analytics Web SDK v3.2.0 | MIT License | Development Build */
1
+ /* Grain Analytics Web SDK v3.2.2 | MIT License | Development Build */
2
2
  "use strict";
3
3
  var Grain = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -5851,8 +5851,11 @@ var Grain = (() => {
5851
5851
  // Snapshot capture state
5852
5852
  this.snapshotCaptured = false;
5853
5853
  this.snapshotEnabled = false;
5854
+ // Device type detection
5855
+ this.deviceType = "desktop";
5854
5856
  this.tracker = tracker;
5855
5857
  this.options = { ...DEFAULT_OPTIONS2, ...options };
5858
+ this.deviceType = this.detectDeviceType();
5856
5859
  this.attentionQuality = new AttentionQualityManager(
5857
5860
  tracker.getActivityDetector(),
5858
5861
  {
@@ -5892,6 +5895,33 @@ var Grain = (() => {
5892
5895
  /**
5893
5896
  * Check remote config for snapshot capture enablement
5894
5897
  */
5898
+ /**
5899
+ * Detect device type based on viewport width and user agent
5900
+ * Mobile: width < 768px OR mobile user agent
5901
+ * Desktop: width >= 768px AND non-mobile user agent
5902
+ */
5903
+ detectDeviceType() {
5904
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
5905
+ return "desktop";
5906
+ }
5907
+ const width = window.innerWidth || window.screen?.width || 0;
5908
+ const isMobileWidth = width < 768;
5909
+ const userAgent = navigator.userAgent.toLowerCase();
5910
+ const mobileKeywords = [
5911
+ "mobile",
5912
+ "android",
5913
+ "iphone",
5914
+ "ipad",
5915
+ "ipod",
5916
+ "blackberry",
5917
+ "windows phone",
5918
+ "webos"
5919
+ ];
5920
+ const isMobileUserAgent = mobileKeywords.some(
5921
+ (keyword) => userAgent.includes(keyword)
5922
+ );
5923
+ return isMobileWidth || isMobileUserAgent ? "mobile" : "desktop";
5924
+ }
5895
5925
  /**
5896
5926
  * Normalize URL by removing query params and hash, and stripping www prefix
5897
5927
  * This ensures heatmap data is aggregated by page, not by URL variations
@@ -5925,6 +5955,11 @@ var Grain = (() => {
5925
5955
  async captureSnapshot() {
5926
5956
  if (this.snapshotCaptured || !this.snapshotEnabled)
5927
5957
  return;
5958
+ if (!this.canUploadSnapshot()) {
5959
+ this.log("Snapshot upload limit reached or URL already captured today");
5960
+ this.snapshotCaptured = true;
5961
+ return;
5962
+ }
5928
5963
  try {
5929
5964
  this.log("Capturing DOM snapshot...");
5930
5965
  const rrwebSnapshot = await Promise.resolve().then(() => (init_rrweb_snapshot(), rrweb_snapshot_exports));
@@ -5974,7 +6009,8 @@ var Grain = (() => {
5974
6009
  sessionId,
5975
6010
  pageUrl,
5976
6011
  snapshot: JSON.stringify(snapshot2),
5977
- timestamp: Date.now()
6012
+ timestamp: Date.now(),
6013
+ deviceType: this.deviceType
5978
6014
  })
5979
6015
  }
5980
6016
  );
@@ -5983,10 +6019,84 @@ var Grain = (() => {
5983
6019
  }
5984
6020
  const result2 = await response.json();
5985
6021
  this.log("Snapshot uploaded successfully:", result2);
6022
+ this.recordSnapshotUpload(pageUrl);
5986
6023
  } catch (error) {
5987
6024
  this.log("Failed to upload snapshot:", error);
5988
6025
  }
5989
6026
  }
6027
+ /**
6028
+ * Check if snapshot can be uploaded based on daily limits
6029
+ * - Max 5 snapshots per user per day
6030
+ * - Same URL (without query params) can't be uploaded twice in same day
6031
+ */
6032
+ canUploadSnapshot() {
6033
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
6034
+ return true;
6035
+ }
6036
+ try {
6037
+ const pageUrl = this.normalizeUrl(window.location.href);
6038
+ const urlWithoutQuery = this.stripQueryParams(pageUrl);
6039
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
6040
+ const storageKey = "_grain_snapshots";
6041
+ const stored = localStorage.getItem(storageKey);
6042
+ let snapshots = stored ? JSON.parse(stored) : [];
6043
+ const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1e3;
6044
+ snapshots = snapshots.filter((s) => s.timestamp > twoDaysAgo);
6045
+ const todaySnapshots = snapshots.filter((s) => s.date === today);
6046
+ if (todaySnapshots.length >= 5) {
6047
+ this.log("Daily snapshot limit reached (5/5)");
6048
+ return false;
6049
+ }
6050
+ const urlAlreadyUploaded = todaySnapshots.some(
6051
+ (s) => this.stripQueryParams(s.url) === urlWithoutQuery
6052
+ );
6053
+ if (urlAlreadyUploaded) {
6054
+ this.log(`Snapshot for ${urlWithoutQuery} already uploaded today`);
6055
+ return false;
6056
+ }
6057
+ this.log(`Snapshot upload allowed (${todaySnapshots.length}/5 today)`);
6058
+ return true;
6059
+ } catch (error) {
6060
+ this.log("Error checking snapshot limits:", error);
6061
+ return true;
6062
+ }
6063
+ }
6064
+ /**
6065
+ * Record a successful snapshot upload
6066
+ */
6067
+ recordSnapshotUpload(pageUrl) {
6068
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
6069
+ return;
6070
+ }
6071
+ try {
6072
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
6073
+ const storageKey = "_grain_snapshots";
6074
+ const stored = localStorage.getItem(storageKey);
6075
+ let snapshots = stored ? JSON.parse(stored) : [];
6076
+ snapshots.push({
6077
+ url: pageUrl,
6078
+ date: today,
6079
+ timestamp: Date.now()
6080
+ });
6081
+ const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1e3;
6082
+ snapshots = snapshots.filter((s) => s.timestamp > twoDaysAgo);
6083
+ localStorage.setItem(storageKey, JSON.stringify(snapshots));
6084
+ this.log(`Snapshot upload recorded: ${snapshots.filter((s) => s.date === today).length}/5 today`);
6085
+ } catch (error) {
6086
+ this.log("Error recording snapshot upload:", error);
6087
+ }
6088
+ }
6089
+ /**
6090
+ * Strip query parameters from URL for comparison
6091
+ */
6092
+ stripQueryParams(url) {
6093
+ try {
6094
+ const urlObj = new URL(url);
6095
+ return `${urlObj.origin}${urlObj.pathname}`;
6096
+ } catch {
6097
+ return url.split("?")[0];
6098
+ }
6099
+ }
5990
6100
  /**
5991
6101
  * Get API URL from tracker configuration
5992
6102
  */
@@ -6102,7 +6212,8 @@ var Grain = (() => {
6102
6212
  entryTimestamp: this.currentScrollState.entryTime,
6103
6213
  exitTimestamp: currentTime,
6104
6214
  pageHeight,
6105
- viewportHeight
6215
+ viewportHeight,
6216
+ deviceType: this.deviceType
6106
6217
  };
6107
6218
  this.tracker.trackSystemEvent("_grain_heatmap_scroll", {
6108
6219
  page_url: scrollData.pageUrl,
@@ -6113,6 +6224,7 @@ var Grain = (() => {
6113
6224
  exit_timestamp: scrollData.exitTimestamp,
6114
6225
  page_height: scrollData.pageHeight,
6115
6226
  viewport_height: scrollData.viewportHeight,
6227
+ device_type: scrollData.deviceType,
6116
6228
  is_split: true
6117
6229
  // Flag to indicate periodic tracking, not final exit
6118
6230
  }, { flush: true });
@@ -6140,7 +6252,8 @@ var Grain = (() => {
6140
6252
  entryTimestamp: this.currentScrollState.entryTime,
6141
6253
  exitTimestamp: currentTime,
6142
6254
  pageHeight: document.documentElement.scrollHeight,
6143
- viewportHeight: window.innerHeight
6255
+ viewportHeight: window.innerHeight,
6256
+ deviceType: this.deviceType
6144
6257
  };
6145
6258
  this.pendingScrolls.push(scrollData);
6146
6259
  }
@@ -6189,7 +6302,8 @@ var Grain = (() => {
6189
6302
  pageY,
6190
6303
  elementTag,
6191
6304
  elementText: elementText || void 0,
6192
- timestamp: Date.now()
6305
+ timestamp: Date.now(),
6306
+ deviceType: this.deviceType
6193
6307
  };
6194
6308
  const isNavigationLink = element instanceof HTMLAnchorElement && element.href;
6195
6309
  if (isNavigationLink) {
@@ -6205,7 +6319,8 @@ var Grain = (() => {
6205
6319
  page_y: clickData.pageY,
6206
6320
  element_tag: clickData.elementTag,
6207
6321
  element_text: clickData.elementText,
6208
- timestamp: clickData.timestamp
6322
+ timestamp: clickData.timestamp,
6323
+ device_type: clickData.deviceType
6209
6324
  }, { flush: true });
6210
6325
  } else {
6211
6326
  this.pendingClicks.push(clickData);
@@ -6240,7 +6355,8 @@ var Grain = (() => {
6240
6355
  entryTimestamp: this.currentScrollState.entryTime,
6241
6356
  exitTimestamp: currentTime,
6242
6357
  pageHeight,
6243
- viewportHeight
6358
+ viewportHeight,
6359
+ deviceType: this.deviceType
6244
6360
  };
6245
6361
  this.pendingScrolls.push(scrollData);
6246
6362
  }
@@ -6360,7 +6476,8 @@ var Grain = (() => {
6360
6476
  page_y: clickData.pageY,
6361
6477
  element_tag: clickData.elementTag,
6362
6478
  element_text: clickData.elementText,
6363
- timestamp: clickData.timestamp
6479
+ timestamp: clickData.timestamp,
6480
+ device_type: clickData.deviceType
6364
6481
  });
6365
6482
  }
6366
6483
  this.pendingClicks = [];
@@ -6375,7 +6492,8 @@ var Grain = (() => {
6375
6492
  entry_timestamp: scrollData.entryTimestamp,
6376
6493
  exit_timestamp: scrollData.exitTimestamp,
6377
6494
  page_height: scrollData.pageHeight,
6378
- viewport_height: scrollData.viewportHeight
6495
+ viewport_height: scrollData.viewportHeight,
6496
+ device_type: scrollData.deviceType
6379
6497
  });
6380
6498
  }
6381
6499
  this.pendingScrolls = [];
@@ -6403,7 +6521,8 @@ var Grain = (() => {
6403
6521
  page_y: clickData.pageY,
6404
6522
  element_tag: clickData.elementTag,
6405
6523
  element_text: clickData.elementText,
6406
- timestamp: clickData.timestamp
6524
+ timestamp: clickData.timestamp,
6525
+ device_type: clickData.deviceType
6407
6526
  }, { flush: true });
6408
6527
  }
6409
6528
  this.pendingClicks = [];
@@ -6418,7 +6537,8 @@ var Grain = (() => {
6418
6537
  entry_timestamp: scrollData.entryTimestamp,
6419
6538
  exit_timestamp: scrollData.exitTimestamp,
6420
6539
  page_height: scrollData.pageHeight,
6421
- viewport_height: scrollData.viewportHeight
6540
+ viewport_height: scrollData.viewportHeight,
6541
+ device_type: scrollData.deviceType
6422
6542
  }, { flush: true });
6423
6543
  }
6424
6544
  this.pendingScrolls = [];