@grainql/analytics-web 3.1.1 → 3.2.0

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.
@@ -3,6 +3,39 @@
3
3
  * Heatmap Tracking Manager for Grain Analytics
4
4
  * Tracks click interactions and scroll depth across all pages
5
5
  */
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
6
39
  Object.defineProperty(exports, "__esModule", { value: true });
7
40
  exports.HeatmapTrackingManager = void 0;
8
41
  const attention_quality_1 = require("./attention-quality");
@@ -29,6 +62,9 @@ class HeatmapTrackingManager {
29
62
  this.lastScrollPosition = 0;
30
63
  this.lastScrollTime = Date.now();
31
64
  this.SPLIT_DURATION = 3000; // 3 seconds - same as section tracking
65
+ // Snapshot capture state
66
+ this.snapshotCaptured = false;
67
+ this.snapshotEnabled = false;
32
68
  this.tracker = tracker;
33
69
  this.options = { ...DEFAULT_OPTIONS, ...options };
34
70
  // Initialize attention quality manager
@@ -51,10 +87,12 @@ class HeatmapTrackingManager {
51
87
  /**
52
88
  * Initialize heatmap tracking
53
89
  */
54
- initialize() {
90
+ async initialize() {
55
91
  if (this.isDestroyed)
56
92
  return;
57
93
  this.log('Initializing heatmap tracking');
94
+ // Check remote config for snapshot capture
95
+ await this.checkSnapshotConfig();
58
96
  // Setup click tracking
59
97
  this.setupClickTracking();
60
98
  // Setup scroll tracking
@@ -63,6 +101,158 @@ class HeatmapTrackingManager {
63
101
  this.startScrollTracking();
64
102
  // Setup page unload handler for beaconing
65
103
  this.setupUnloadHandler();
104
+ // Initialize snapshot capture if enabled
105
+ if (this.snapshotEnabled && !this.snapshotCaptured) {
106
+ this.captureSnapshot();
107
+ }
108
+ }
109
+ /**
110
+ * Check remote config for snapshot capture enablement
111
+ */
112
+ /**
113
+ * Normalize URL by removing query params and hash, and stripping www prefix
114
+ * This ensures heatmap data is aggregated by page, not by URL variations
115
+ * Subdomains (other than www) are preserved: api.example.com != app.example.com
116
+ */
117
+ normalizeUrl(url) {
118
+ try {
119
+ const urlObj = new URL(url);
120
+ let hostname = urlObj.hostname.toLowerCase();
121
+ // Strip www prefix but keep other subdomains
122
+ if (hostname.startsWith('www.')) {
123
+ hostname = hostname.substring(4);
124
+ }
125
+ // Return protocol + normalized hostname + pathname only
126
+ return `${urlObj.protocol}//${hostname}${urlObj.pathname}`;
127
+ }
128
+ catch {
129
+ // If URL parsing fails, return as-is
130
+ return url;
131
+ }
132
+ }
133
+ async checkSnapshotConfig() {
134
+ try {
135
+ const enableSnapshot = await this.tracker.getConfigAsync('enableHeatmapSnapshot');
136
+ this.snapshotEnabled = enableSnapshot === 'true';
137
+ this.log('Heatmap snapshot capture enabled:', this.snapshotEnabled);
138
+ }
139
+ catch (error) {
140
+ this.log('Failed to check snapshot config, defaulting to disabled:', error);
141
+ this.snapshotEnabled = false;
142
+ }
143
+ }
144
+ /**
145
+ * Capture DOM snapshot using rrweb-snapshot
146
+ */
147
+ async captureSnapshot() {
148
+ if (this.snapshotCaptured || !this.snapshotEnabled)
149
+ return;
150
+ try {
151
+ this.log('Capturing DOM snapshot...');
152
+ // Dynamically import rrweb-snapshot (only if enabled)
153
+ // @ts-ignore - rrweb-snapshot is an optional dependency, may not be resolvable during React build
154
+ const rrwebSnapshot = await Promise.resolve().then(() => __importStar(require('rrweb-snapshot')));
155
+ // Capture full DOM snapshot with PII masking
156
+ const snapshot = rrwebSnapshot.snapshot(document, {
157
+ maskAllInputs: true,
158
+ maskTextFn: (text) => {
159
+ // Basic PII masking - mask anything that looks like email or sensitive data
160
+ return text.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '*****');
161
+ },
162
+ });
163
+ this.snapshotCaptured = true;
164
+ this.log('DOM snapshot captured successfully');
165
+ // Upload snapshot to backend
166
+ await this.uploadSnapshot(snapshot);
167
+ }
168
+ catch (error) {
169
+ this.log('Failed to capture DOM snapshot:', error);
170
+ }
171
+ }
172
+ /**
173
+ * Upload snapshot to backend
174
+ */
175
+ async uploadSnapshot(snapshot) {
176
+ try {
177
+ const sessionId = this.tracker.getEffectiveUserId();
178
+ const pageUrl = this.normalizeUrl(window.location.href);
179
+ this.log('Uploading snapshot to backend - sessionId:', sessionId);
180
+ // Note: The actual API call would need the tenantId from the tracker
181
+ // For now, we'll log that the snapshot is ready to be uploaded
182
+ // The tracker would need to expose tenantId for this to work
183
+ const snapshotData = {
184
+ sessionId,
185
+ pageUrl,
186
+ snapshot
187
+ };
188
+ this.log('Snapshot data prepared:', { sessionId, pageUrl, snapshotSize: JSON.stringify(snapshot).length });
189
+ // Get API configuration from tracker
190
+ const apiUrl = await this.getApiUrl();
191
+ const tenantId = await this.getTenantId();
192
+ const headers = await this.getAuthHeaders();
193
+ if (!apiUrl || !tenantId) {
194
+ this.log('Cannot upload snapshot: missing API URL or tenant ID');
195
+ return;
196
+ }
197
+ // Upload to backend
198
+ const response = await fetch(`${apiUrl}/v1/events/${encodeURIComponent(tenantId)}/snapshot`, {
199
+ method: 'POST',
200
+ headers: {
201
+ 'Content-Type': 'application/json',
202
+ ...headers
203
+ },
204
+ body: JSON.stringify({
205
+ sessionId,
206
+ pageUrl,
207
+ snapshot: JSON.stringify(snapshot),
208
+ timestamp: Date.now()
209
+ })
210
+ });
211
+ if (!response.ok) {
212
+ throw new Error(`Snapshot upload failed: ${response.status}`);
213
+ }
214
+ const result = await response.json();
215
+ this.log('Snapshot uploaded successfully:', result);
216
+ }
217
+ catch (error) {
218
+ this.log('Failed to upload snapshot:', error);
219
+ }
220
+ }
221
+ /**
222
+ * Get API URL from tracker configuration
223
+ */
224
+ async getApiUrl() {
225
+ try {
226
+ return this.tracker.config?.apiUrl || 'https://api.grainql.com';
227
+ }
228
+ catch {
229
+ return 'https://api.grainql.com';
230
+ }
231
+ }
232
+ /**
233
+ * Get tenant ID from tracker configuration
234
+ */
235
+ async getTenantId() {
236
+ try {
237
+ return this.tracker.config?.tenantId;
238
+ }
239
+ catch {
240
+ return undefined;
241
+ }
242
+ }
243
+ /**
244
+ * Get auth headers from tracker
245
+ */
246
+ async getAuthHeaders() {
247
+ try {
248
+ if (typeof this.tracker.getAuthHeaders === 'function') {
249
+ return await this.tracker.getAuthHeaders();
250
+ }
251
+ return {};
252
+ }
253
+ catch {
254
+ return {};
255
+ }
66
256
  }
67
257
  /**
68
258
  * Setup click event tracking
@@ -151,7 +341,7 @@ class HeatmapTrackingManager {
151
341
  return;
152
342
  }
153
343
  const scrollData = {
154
- pageUrl: window.location.href,
344
+ pageUrl: this.normalizeUrl(window.location.href),
155
345
  viewportSection: this.currentScrollState.viewportSection,
156
346
  scrollDepthPx: scrollY,
157
347
  durationMs: duration,
@@ -192,7 +382,7 @@ class HeatmapTrackingManager {
192
382
  const duration = currentTime - this.currentScrollState.entryTime;
193
383
  if (duration > 100) {
194
384
  const scrollData = {
195
- pageUrl: window.location.href,
385
+ pageUrl: this.normalizeUrl(window.location.href),
196
386
  viewportSection: this.currentScrollState.viewportSection,
197
387
  scrollDepthPx: this.currentScrollState.scrollDepthPx,
198
388
  durationMs: duration,
@@ -220,8 +410,23 @@ class HeatmapTrackingManager {
220
410
  const element = event.target;
221
411
  if (!element)
222
412
  return;
223
- const pageUrl = window.location.href;
413
+ const pageUrl = this.normalizeUrl(window.location.href);
224
414
  const xpath = this.generateXPath(element);
415
+ // Generate CSS selector for element-relative positioning
416
+ const selector = this.generateCSSSelector(element);
417
+ // Calculate element-relative coordinates
418
+ let relX;
419
+ let relY;
420
+ try {
421
+ const rect = element.getBoundingClientRect();
422
+ if (rect.width > 0 && rect.height > 0) {
423
+ relX = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
424
+ relY = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
425
+ }
426
+ }
427
+ catch (error) {
428
+ this.log('Failed to calculate relative coordinates:', error);
429
+ }
225
430
  // Get viewport coordinates
226
431
  const viewportX = Math.round(event.clientX);
227
432
  const viewportY = Math.round(event.clientY);
@@ -233,6 +438,9 @@ class HeatmapTrackingManager {
233
438
  const clickData = {
234
439
  pageUrl,
235
440
  xpath,
441
+ selector,
442
+ relX,
443
+ relY,
236
444
  viewportX,
237
445
  viewportY,
238
446
  pageX,
@@ -248,6 +456,9 @@ class HeatmapTrackingManager {
248
456
  this.tracker.trackSystemEvent('_grain_heatmap_click', {
249
457
  page_url: clickData.pageUrl,
250
458
  xpath: clickData.xpath,
459
+ selector: clickData.selector,
460
+ rel_x: clickData.relX,
461
+ rel_y: clickData.relY,
251
462
  viewport_x: clickData.viewportX,
252
463
  viewport_y: clickData.viewportY,
253
464
  page_x: clickData.pageX,
@@ -291,7 +502,7 @@ class HeatmapTrackingManager {
291
502
  // Only record if duration is meaningful (> 100ms)
292
503
  if (duration > 100) {
293
504
  const scrollData = {
294
- pageUrl: window.location.href,
505
+ pageUrl: this.normalizeUrl(window.location.href),
295
506
  viewportSection: this.currentScrollState.viewportSection,
296
507
  scrollDepthPx: this.currentScrollState.scrollDepthPx,
297
508
  durationMs: duration,
@@ -319,6 +530,50 @@ class HeatmapTrackingManager {
319
530
  // Check if we should flush
320
531
  this.considerBatchFlush();
321
532
  }
533
+ /**
534
+ * Generate CSS selector for an element (for element-relative positioning)
535
+ */
536
+ generateCSSSelector(element) {
537
+ if (!element)
538
+ return '';
539
+ // Prefer ID if available
540
+ if (element.id) {
541
+ return `#${element.id}`;
542
+ }
543
+ // Try data attributes
544
+ const dataAttrs = Array.from(element.attributes).filter(attr => attr.name.startsWith('data-'));
545
+ if (dataAttrs.length > 0) {
546
+ const attr = dataAttrs[0];
547
+ return `${element.tagName.toLowerCase()}[${attr.name}="${attr.value}"]`;
548
+ }
549
+ // Try class names (pick first meaningful class)
550
+ if (element.className && typeof element.className === 'string') {
551
+ const classes = element.className.split(' ').filter(c => c && !c.match(/^(active|hover|focus)/));
552
+ if (classes.length > 0) {
553
+ return `${element.tagName.toLowerCase()}.${classes[0]}`;
554
+ }
555
+ }
556
+ // Fall back to nth-child path
557
+ const path = [];
558
+ let current = element;
559
+ while (current && current !== document.body) {
560
+ let selector = current.tagName.toLowerCase();
561
+ if (current.parentElement) {
562
+ const siblings = Array.from(current.parentElement.children);
563
+ const sameTagSiblings = siblings.filter(s => s.tagName === current.tagName);
564
+ if (sameTagSiblings.length > 1) {
565
+ const index = sameTagSiblings.indexOf(current) + 1;
566
+ selector += `:nth-child(${index})`;
567
+ }
568
+ }
569
+ path.unshift(selector);
570
+ current = current.parentElement;
571
+ // Limit path depth to avoid overly long selectors
572
+ if (path.length >= 5)
573
+ break;
574
+ }
575
+ return path.join(' > ');
576
+ }
322
577
  /**
323
578
  * Generate XPath for an element
324
579
  */
@@ -380,6 +635,9 @@ class HeatmapTrackingManager {
380
635
  this.tracker.trackSystemEvent('_grain_heatmap_click', {
381
636
  page_url: clickData.pageUrl,
382
637
  xpath: clickData.xpath,
638
+ selector: clickData.selector,
639
+ rel_x: clickData.relX,
640
+ rel_y: clickData.relY,
383
641
  viewport_x: clickData.viewportX,
384
642
  viewport_y: clickData.viewportY,
385
643
  page_x: clickData.pageX,
@@ -425,6 +683,9 @@ class HeatmapTrackingManager {
425
683
  this.tracker.trackSystemEvent('_grain_heatmap_click', {
426
684
  page_url: clickData.pageUrl,
427
685
  xpath: clickData.xpath,
686
+ selector: clickData.selector,
687
+ rel_x: clickData.relX,
688
+ rel_y: clickData.relY,
428
689
  viewport_x: clickData.viewportX,
429
690
  viewport_y: clickData.viewportY,
430
691
  page_x: clickData.pageX,