@agentuity/frontend 0.0.111 → 0.1.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.
- package/dist/analytics/beacon.d.ts +15 -0
- package/dist/analytics/beacon.d.ts.map +1 -0
- package/dist/analytics/beacon.js +177 -0
- package/dist/analytics/beacon.js.map +1 -0
- package/dist/analytics/collectors/clicks.d.ts +10 -0
- package/dist/analytics/collectors/clicks.d.ts.map +1 -0
- package/dist/analytics/collectors/clicks.js +84 -0
- package/dist/analytics/collectors/clicks.js.map +1 -0
- package/dist/analytics/collectors/errors.d.ts +5 -0
- package/dist/analytics/collectors/errors.d.ts.map +1 -0
- package/dist/analytics/collectors/errors.js +43 -0
- package/dist/analytics/collectors/errors.js.map +1 -0
- package/dist/analytics/collectors/forms.d.ts +5 -0
- package/dist/analytics/collectors/forms.d.ts.map +1 -0
- package/dist/analytics/collectors/forms.js +55 -0
- package/dist/analytics/collectors/forms.js.map +1 -0
- package/dist/analytics/collectors/pageview.d.ts +15 -0
- package/dist/analytics/collectors/pageview.d.ts.map +1 -0
- package/dist/analytics/collectors/pageview.js +64 -0
- package/dist/analytics/collectors/pageview.js.map +1 -0
- package/dist/analytics/collectors/scroll.d.ts +17 -0
- package/dist/analytics/collectors/scroll.d.ts.map +1 -0
- package/dist/analytics/collectors/scroll.js +93 -0
- package/dist/analytics/collectors/scroll.js.map +1 -0
- package/dist/analytics/collectors/spa.d.ts +10 -0
- package/dist/analytics/collectors/spa.d.ts.map +1 -0
- package/dist/analytics/collectors/spa.js +53 -0
- package/dist/analytics/collectors/spa.js.map +1 -0
- package/dist/analytics/collectors/visibility.d.ts +18 -0
- package/dist/analytics/collectors/visibility.d.ts.map +1 -0
- package/dist/analytics/collectors/visibility.js +81 -0
- package/dist/analytics/collectors/visibility.js.map +1 -0
- package/dist/analytics/collectors/webvitals.d.ts +6 -0
- package/dist/analytics/collectors/webvitals.d.ts.map +1 -0
- package/dist/analytics/collectors/webvitals.js +111 -0
- package/dist/analytics/collectors/webvitals.js.map +1 -0
- package/dist/analytics/events.d.ts +18 -0
- package/dist/analytics/events.d.ts.map +1 -0
- package/dist/analytics/events.js +126 -0
- package/dist/analytics/events.js.map +1 -0
- package/dist/analytics/index.d.ts +12 -0
- package/dist/analytics/index.d.ts.map +1 -0
- package/dist/analytics/index.js +12 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/analytics/offline.d.ts +19 -0
- package/dist/analytics/offline.d.ts.map +1 -0
- package/dist/analytics/offline.js +145 -0
- package/dist/analytics/offline.js.map +1 -0
- package/dist/analytics/types.d.ts +113 -0
- package/dist/analytics/types.d.ts.map +1 -0
- package/dist/analytics/types.js +2 -0
- package/dist/analytics/types.js.map +1 -0
- package/dist/analytics/utils/storage.d.ts +13 -0
- package/dist/analytics/utils/storage.d.ts.map +1 -0
- package/dist/analytics/utils/storage.js +63 -0
- package/dist/analytics/utils/storage.js.map +1 -0
- package/dist/analytics/utils/utm.d.ts +12 -0
- package/dist/analytics/utils/utm.d.ts.map +1 -0
- package/dist/analytics/utils/utm.js +27 -0
- package/dist/analytics/utils/utm.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/analytics/beacon.ts +203 -0
- package/src/analytics/collectors/clicks.ts +100 -0
- package/src/analytics/collectors/errors.ts +49 -0
- package/src/analytics/collectors/forms.ts +64 -0
- package/src/analytics/collectors/pageview.ts +76 -0
- package/src/analytics/collectors/scroll.ts +112 -0
- package/src/analytics/collectors/spa.ts +60 -0
- package/src/analytics/collectors/visibility.ts +94 -0
- package/src/analytics/collectors/webvitals.ts +129 -0
- package/src/analytics/events.ts +144 -0
- package/src/analytics/index.ts +21 -0
- package/src/analytics/offline.ts +163 -0
- package/src/analytics/types.ts +139 -0
- package/src/analytics/utils/storage.ts +64 -0
- package/src/analytics/utils/utm.ts +36 -0
- package/src/index.ts +18 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createBaseEvent } from './pageview';
|
|
2
|
+
import { queueEvent } from '../events';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Initialize click tracking
|
|
6
|
+
* Tracks clicks on elements with data-analytics attribute
|
|
7
|
+
*/
|
|
8
|
+
export function initClickTracking(): void {
|
|
9
|
+
if (typeof document === 'undefined') {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
document.addEventListener(
|
|
14
|
+
'click',
|
|
15
|
+
(e) => {
|
|
16
|
+
const target = e.target as HTMLElement | null;
|
|
17
|
+
if (!target) return;
|
|
18
|
+
|
|
19
|
+
// Find closest element with data-analytics attribute
|
|
20
|
+
const analyticsElement = target.closest('[data-analytics]');
|
|
21
|
+
if (!analyticsElement) return;
|
|
22
|
+
|
|
23
|
+
const eventName = analyticsElement.getAttribute('data-analytics');
|
|
24
|
+
if (!eventName) return;
|
|
25
|
+
|
|
26
|
+
const event = createBaseEvent('click');
|
|
27
|
+
event.event_name = eventName;
|
|
28
|
+
|
|
29
|
+
// Collect additional data attributes
|
|
30
|
+
const eventData: Record<string, unknown> = {};
|
|
31
|
+
for (const attr of Array.from(analyticsElement.attributes)) {
|
|
32
|
+
if (attr.name.startsWith('data-analytics-')) {
|
|
33
|
+
const key = attr.name.replace('data-analytics-', '');
|
|
34
|
+
eventData[key] = attr.value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Add element info
|
|
39
|
+
eventData.tag = analyticsElement.tagName.toLowerCase();
|
|
40
|
+
if (analyticsElement.id) {
|
|
41
|
+
eventData.id = analyticsElement.id;
|
|
42
|
+
}
|
|
43
|
+
const text = (analyticsElement as HTMLElement).innerText?.slice(0, 100);
|
|
44
|
+
if (text) {
|
|
45
|
+
eventData.text = text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (Object.keys(eventData).length > 0) {
|
|
49
|
+
event.event_data = eventData;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
queueEvent(event);
|
|
53
|
+
},
|
|
54
|
+
{ capture: true, passive: true }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Initialize outbound link tracking
|
|
60
|
+
*/
|
|
61
|
+
export function initOutboundLinkTracking(): void {
|
|
62
|
+
if (typeof document === 'undefined') {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
document.addEventListener(
|
|
67
|
+
'click',
|
|
68
|
+
(e) => {
|
|
69
|
+
const target = e.target as HTMLElement | null;
|
|
70
|
+
if (!target) return;
|
|
71
|
+
|
|
72
|
+
const link = target.closest('a');
|
|
73
|
+
if (!link) return;
|
|
74
|
+
|
|
75
|
+
const href = link.href;
|
|
76
|
+
if (!href) return;
|
|
77
|
+
|
|
78
|
+
// Check if it's an outbound link
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(href, window.location.origin);
|
|
81
|
+
if (url.hostname === window.location.hostname) {
|
|
82
|
+
return; // Same domain, not outbound
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const event = createBaseEvent('outbound_link');
|
|
86
|
+
event.event_name = 'outbound_link';
|
|
87
|
+
event.event_data = {
|
|
88
|
+
href,
|
|
89
|
+
hostname: url.hostname,
|
|
90
|
+
text: link.innerText?.slice(0, 100) || '',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
queueEvent(event);
|
|
94
|
+
} catch {
|
|
95
|
+
// Invalid URL, ignore
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{ capture: true, passive: true }
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createBaseEvent } from './pageview';
|
|
2
|
+
import { queueEvent } from '../events';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Initialize JavaScript error tracking
|
|
6
|
+
*/
|
|
7
|
+
export function initErrorTracking(): void {
|
|
8
|
+
if (typeof window === 'undefined') {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Handle uncaught errors
|
|
13
|
+
window.addEventListener('error', (e) => {
|
|
14
|
+
const event = createBaseEvent('error');
|
|
15
|
+
event.event_name = 'js_error';
|
|
16
|
+
event.event_data = {
|
|
17
|
+
message: e.message || 'Unknown error',
|
|
18
|
+
filename: e.filename || '',
|
|
19
|
+
lineno: e.lineno || 0,
|
|
20
|
+
colno: e.colno || 0,
|
|
21
|
+
stack: e.error?.stack?.slice(0, 1000) || '',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
queueEvent(event);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Handle unhandled promise rejections
|
|
28
|
+
window.addEventListener('unhandledrejection', (e) => {
|
|
29
|
+
const event = createBaseEvent('error');
|
|
30
|
+
event.event_name = 'unhandled_rejection';
|
|
31
|
+
|
|
32
|
+
let message = 'Unhandled Promise Rejection';
|
|
33
|
+
let stack = '';
|
|
34
|
+
|
|
35
|
+
if (e.reason instanceof Error) {
|
|
36
|
+
message = e.reason.message;
|
|
37
|
+
stack = e.reason.stack?.slice(0, 1000) || '';
|
|
38
|
+
} else if (typeof e.reason === 'string') {
|
|
39
|
+
message = e.reason;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
event.event_data = {
|
|
43
|
+
message,
|
|
44
|
+
stack,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
queueEvent(event);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createBaseEvent } from './pageview';
|
|
2
|
+
import { queueEvent } from '../events';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Initialize form submission tracking
|
|
6
|
+
*/
|
|
7
|
+
export function initFormTracking(): void {
|
|
8
|
+
if (typeof document === 'undefined') {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
document.addEventListener(
|
|
13
|
+
'submit',
|
|
14
|
+
(e) => {
|
|
15
|
+
const form = e.target as HTMLFormElement | null;
|
|
16
|
+
if (!form || form.tagName !== 'FORM') {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const event = createBaseEvent('form_submit');
|
|
21
|
+
event.event_name = 'form_submit';
|
|
22
|
+
|
|
23
|
+
const eventData: Record<string, unknown> = {};
|
|
24
|
+
|
|
25
|
+
// Form identification
|
|
26
|
+
if (form.id) {
|
|
27
|
+
eventData.form_id = form.id;
|
|
28
|
+
}
|
|
29
|
+
if (form.name) {
|
|
30
|
+
eventData.form_name = form.name;
|
|
31
|
+
}
|
|
32
|
+
if (form.action) {
|
|
33
|
+
eventData.form_action = form.action;
|
|
34
|
+
}
|
|
35
|
+
eventData.form_method = form.method || 'get';
|
|
36
|
+
|
|
37
|
+
// Count form fields (don't capture values for privacy)
|
|
38
|
+
const inputs = form.querySelectorAll('input, select, textarea');
|
|
39
|
+
eventData.field_count = inputs.length;
|
|
40
|
+
|
|
41
|
+
// Check for common form types
|
|
42
|
+
const hasEmail = form.querySelector('input[type="email"]') !== null;
|
|
43
|
+
const hasPassword = form.querySelector('input[type="password"]') !== null;
|
|
44
|
+
const hasSearch = form.querySelector('input[type="search"]') !== null;
|
|
45
|
+
|
|
46
|
+
if (hasEmail && hasPassword) {
|
|
47
|
+
eventData.form_type = 'auth';
|
|
48
|
+
} else if (hasEmail) {
|
|
49
|
+
eventData.form_type = 'email';
|
|
50
|
+
} else if (hasSearch) {
|
|
51
|
+
eventData.form_type = 'search';
|
|
52
|
+
} else if (hasPassword) {
|
|
53
|
+
eventData.form_type = 'password';
|
|
54
|
+
} else {
|
|
55
|
+
eventData.form_type = 'other';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
event.event_data = eventData;
|
|
59
|
+
|
|
60
|
+
queueEvent(event);
|
|
61
|
+
},
|
|
62
|
+
{ capture: true }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AnalyticsEvent } from '../types';
|
|
2
|
+
import { queueEvent } from '../events';
|
|
3
|
+
import { getUTMParams } from '../utils/utm';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a base event with common properties
|
|
7
|
+
*/
|
|
8
|
+
export function createBaseEvent(eventType: AnalyticsEvent['event_type']): AnalyticsEvent {
|
|
9
|
+
const utm = getUTMParams();
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
id: crypto.randomUUID
|
|
13
|
+
? crypto.randomUUID()
|
|
14
|
+
: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
15
|
+
timestamp: Date.now(),
|
|
16
|
+
timezone_offset: new Date().getTimezoneOffset(),
|
|
17
|
+
|
|
18
|
+
event_type: eventType,
|
|
19
|
+
|
|
20
|
+
url: window.location.href,
|
|
21
|
+
path: window.location.pathname,
|
|
22
|
+
referrer: document.referrer || '',
|
|
23
|
+
title: document.title || '',
|
|
24
|
+
|
|
25
|
+
screen_width: window.screen?.width || 0,
|
|
26
|
+
screen_height: window.screen?.height || 0,
|
|
27
|
+
viewport_width: window.innerWidth || 0,
|
|
28
|
+
viewport_height: window.innerHeight || 0,
|
|
29
|
+
device_pixel_ratio: window.devicePixelRatio || 1,
|
|
30
|
+
user_agent: navigator.userAgent || '',
|
|
31
|
+
language: navigator.language || '',
|
|
32
|
+
|
|
33
|
+
...utm,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Track a pageview event
|
|
39
|
+
*/
|
|
40
|
+
export function trackPageview(customPath?: string): void {
|
|
41
|
+
const event = createBaseEvent('pageview');
|
|
42
|
+
|
|
43
|
+
if (customPath) {
|
|
44
|
+
event.path = customPath;
|
|
45
|
+
event.url = window.location.origin + customPath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Add performance timing if available
|
|
49
|
+
if (typeof performance !== 'undefined') {
|
|
50
|
+
const timing = performance.getEntriesByType('navigation')[0] as
|
|
51
|
+
| PerformanceNavigationTiming
|
|
52
|
+
| undefined;
|
|
53
|
+
if (timing) {
|
|
54
|
+
event.load_time = Math.round(timing.loadEventEnd - timing.startTime);
|
|
55
|
+
event.dom_ready = Math.round(timing.domContentLoadedEventEnd - timing.startTime);
|
|
56
|
+
event.ttfb = Math.round(timing.responseStart - timing.requestStart);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
queueEvent(event);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Initialize pageview tracking
|
|
65
|
+
* Tracks initial pageview when called
|
|
66
|
+
*/
|
|
67
|
+
export function initPageviewTracking(): void {
|
|
68
|
+
// Track initial pageview after DOM is ready
|
|
69
|
+
if (document.readyState === 'complete') {
|
|
70
|
+
trackPageview();
|
|
71
|
+
} else {
|
|
72
|
+
window.addEventListener('load', () => {
|
|
73
|
+
trackPageview();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createBaseEvent } from './pageview';
|
|
2
|
+
import { queueEvent } from '../events';
|
|
3
|
+
|
|
4
|
+
const SCROLL_MILESTONES = [25, 50, 75, 100];
|
|
5
|
+
let trackedMilestones: Set<number> = new Set();
|
|
6
|
+
let maxScrollDepth = 0;
|
|
7
|
+
let isScrollTrackingInitialized = false;
|
|
8
|
+
let scrollHandler: (() => void) | null = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calculate current scroll depth percentage
|
|
12
|
+
*/
|
|
13
|
+
function getScrollDepth(): number {
|
|
14
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
|
19
|
+
const scrollHeight =
|
|
20
|
+
document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
|
21
|
+
|
|
22
|
+
if (scrollHeight <= 0) {
|
|
23
|
+
return 100; // Page doesn't scroll
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return Math.min(100, Math.round((scrollTop / scrollHeight) * 100));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Handle scroll event
|
|
31
|
+
*/
|
|
32
|
+
function handleScroll(): void {
|
|
33
|
+
const depth = getScrollDepth();
|
|
34
|
+
|
|
35
|
+
if (depth > maxScrollDepth) {
|
|
36
|
+
maxScrollDepth = depth;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check for milestone crossings
|
|
40
|
+
for (const milestone of SCROLL_MILESTONES) {
|
|
41
|
+
if (depth >= milestone && !trackedMilestones.has(milestone)) {
|
|
42
|
+
trackedMilestones.add(milestone);
|
|
43
|
+
|
|
44
|
+
const event = createBaseEvent('scroll');
|
|
45
|
+
event.event_name = `scroll_${milestone}`;
|
|
46
|
+
event.scroll_depth = milestone;
|
|
47
|
+
|
|
48
|
+
queueEvent(event);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Initialize scroll depth tracking
|
|
55
|
+
*/
|
|
56
|
+
export function initScrollTracking(): void {
|
|
57
|
+
if (typeof window === 'undefined') {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isScrollTrackingInitialized) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
isScrollTrackingInitialized = true;
|
|
65
|
+
|
|
66
|
+
// Reset on page load
|
|
67
|
+
trackedMilestones = new Set();
|
|
68
|
+
maxScrollDepth = 0;
|
|
69
|
+
|
|
70
|
+
// Throttled scroll handler
|
|
71
|
+
let ticking = false;
|
|
72
|
+
scrollHandler = () => {
|
|
73
|
+
if (!ticking) {
|
|
74
|
+
requestAnimationFrame(() => {
|
|
75
|
+
handleScroll();
|
|
76
|
+
ticking = false;
|
|
77
|
+
});
|
|
78
|
+
ticking = true;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
window.addEventListener('scroll', scrollHandler, { passive: true });
|
|
83
|
+
|
|
84
|
+
// Check initial scroll position (for pages that load scrolled)
|
|
85
|
+
handleScroll();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Remove scroll tracking listener
|
|
90
|
+
*/
|
|
91
|
+
export function removeScrollTracking(): void {
|
|
92
|
+
if (scrollHandler) {
|
|
93
|
+
window.removeEventListener('scroll', scrollHandler);
|
|
94
|
+
scrollHandler = null;
|
|
95
|
+
}
|
|
96
|
+
isScrollTrackingInitialized = false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get max scroll depth (for time on page events)
|
|
101
|
+
*/
|
|
102
|
+
export function getMaxScrollDepth(): number {
|
|
103
|
+
return maxScrollDepth;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reset tracked milestones (for SPA navigation)
|
|
108
|
+
*/
|
|
109
|
+
export function resetScrollTracking(): void {
|
|
110
|
+
trackedMilestones = new Set();
|
|
111
|
+
maxScrollDepth = 0;
|
|
112
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { trackPageview } from './pageview';
|
|
2
|
+
|
|
3
|
+
let currentPath = '';
|
|
4
|
+
let originalPushState: typeof history.pushState | null = null;
|
|
5
|
+
let originalReplaceState: typeof history.replaceState | null = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handle URL change for SPA navigation
|
|
9
|
+
*/
|
|
10
|
+
function handleUrlChange(): void {
|
|
11
|
+
const newPath = window.location.pathname;
|
|
12
|
+
if (newPath !== currentPath) {
|
|
13
|
+
currentPath = newPath;
|
|
14
|
+
trackPageview(newPath);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Initialize SPA navigation tracking
|
|
20
|
+
* Hooks into history.pushState, history.replaceState, and popstate event
|
|
21
|
+
*/
|
|
22
|
+
export function initSPATracking(): void {
|
|
23
|
+
if (typeof window === 'undefined' || typeof history === 'undefined') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
currentPath = window.location.pathname;
|
|
28
|
+
|
|
29
|
+
// Hook into history.pushState
|
|
30
|
+
originalPushState = history.pushState.bind(history);
|
|
31
|
+
history.pushState = function (...args) {
|
|
32
|
+
originalPushState?.apply(this, args);
|
|
33
|
+
handleUrlChange();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Hook into history.replaceState
|
|
37
|
+
originalReplaceState = history.replaceState.bind(history);
|
|
38
|
+
history.replaceState = function (...args) {
|
|
39
|
+
originalReplaceState?.apply(this, args);
|
|
40
|
+
handleUrlChange();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Listen for popstate (back/forward navigation)
|
|
44
|
+
window.addEventListener('popstate', handleUrlChange);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Cleanup SPA tracking (for testing)
|
|
49
|
+
*/
|
|
50
|
+
export function cleanupSPATracking(): void {
|
|
51
|
+
if (originalPushState) {
|
|
52
|
+
history.pushState = originalPushState;
|
|
53
|
+
originalPushState = null;
|
|
54
|
+
}
|
|
55
|
+
if (originalReplaceState) {
|
|
56
|
+
history.replaceState = originalReplaceState;
|
|
57
|
+
originalReplaceState = null;
|
|
58
|
+
}
|
|
59
|
+
window.removeEventListener('popstate', handleUrlChange);
|
|
60
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createBaseEvent } from './pageview';
|
|
2
|
+
import { queueEvent } from '../events';
|
|
3
|
+
import { getMaxScrollDepth } from './scroll';
|
|
4
|
+
|
|
5
|
+
let pageEntryTime = 0;
|
|
6
|
+
let hiddenTime = 0;
|
|
7
|
+
let lastHiddenTimestamp = 0;
|
|
8
|
+
let visibilityTrackingInitialized = false;
|
|
9
|
+
let visibilityChangeHandler: (() => void) | null = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get total time spent on page (excluding hidden time)
|
|
13
|
+
*/
|
|
14
|
+
function getTimeOnPage(): number {
|
|
15
|
+
if (pageEntryTime === 0) {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const totalTime = Date.now() - pageEntryTime;
|
|
20
|
+
return Math.max(0, totalTime - hiddenTime);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Initialize visibility tracking
|
|
25
|
+
* Tracks when user leaves/returns to the page
|
|
26
|
+
*/
|
|
27
|
+
export function initVisibilityTracking(): void {
|
|
28
|
+
if (typeof document === 'undefined') {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (visibilityTrackingInitialized) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
visibilityTrackingInitialized = true;
|
|
36
|
+
|
|
37
|
+
pageEntryTime = Date.now();
|
|
38
|
+
hiddenTime = 0;
|
|
39
|
+
lastHiddenTimestamp = 0;
|
|
40
|
+
|
|
41
|
+
visibilityChangeHandler = () => {
|
|
42
|
+
if (document.visibilityState === 'hidden') {
|
|
43
|
+
lastHiddenTimestamp = Date.now();
|
|
44
|
+
|
|
45
|
+
// Track page leave with engagement metrics
|
|
46
|
+
const event = createBaseEvent('visibility');
|
|
47
|
+
event.event_name = 'page_hidden';
|
|
48
|
+
event.time_on_page = getTimeOnPage();
|
|
49
|
+
event.scroll_depth = getMaxScrollDepth();
|
|
50
|
+
|
|
51
|
+
queueEvent(event);
|
|
52
|
+
} else if (document.visibilityState === 'visible') {
|
|
53
|
+
// Calculate hidden duration
|
|
54
|
+
if (lastHiddenTimestamp > 0) {
|
|
55
|
+
hiddenTime += Date.now() - lastHiddenTimestamp;
|
|
56
|
+
lastHiddenTimestamp = 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const event = createBaseEvent('visibility');
|
|
60
|
+
event.event_name = 'page_visible';
|
|
61
|
+
|
|
62
|
+
queueEvent(event);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
document.addEventListener('visibilitychange', visibilityChangeHandler);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Remove visibility tracking listener
|
|
71
|
+
*/
|
|
72
|
+
export function removeVisibilityTracking(): void {
|
|
73
|
+
if (visibilityChangeHandler) {
|
|
74
|
+
document.removeEventListener('visibilitychange', visibilityChangeHandler);
|
|
75
|
+
visibilityChangeHandler = null;
|
|
76
|
+
}
|
|
77
|
+
visibilityTrackingInitialized = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Reset visibility tracking (for SPA navigation)
|
|
82
|
+
*/
|
|
83
|
+
export function resetVisibilityTracking(): void {
|
|
84
|
+
pageEntryTime = Date.now();
|
|
85
|
+
hiddenTime = 0;
|
|
86
|
+
lastHiddenTimestamp = 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get current time on page
|
|
91
|
+
*/
|
|
92
|
+
export function getCurrentTimeOnPage(): number {
|
|
93
|
+
return getTimeOnPage();
|
|
94
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createBaseEvent } from './pageview';
|
|
2
|
+
import { queueEvent, flushEvents } from '../events';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Initialize Core Web Vitals tracking
|
|
6
|
+
* Uses PerformanceObserver to track LCP, FCP, CLS, INP
|
|
7
|
+
*/
|
|
8
|
+
export function initWebVitalsTracking(): void {
|
|
9
|
+
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Track First Contentful Paint (FCP)
|
|
14
|
+
try {
|
|
15
|
+
const fcpObserver = new PerformanceObserver((list) => {
|
|
16
|
+
for (const entry of list.getEntries()) {
|
|
17
|
+
if (entry.name === 'first-contentful-paint') {
|
|
18
|
+
const event = createBaseEvent('web_vital');
|
|
19
|
+
event.event_name = 'fcp';
|
|
20
|
+
event.fcp = Math.round(entry.startTime);
|
|
21
|
+
queueEvent(event);
|
|
22
|
+
flushEvents();
|
|
23
|
+
fcpObserver.disconnect();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
fcpObserver.observe({ type: 'paint', buffered: true });
|
|
28
|
+
} catch {
|
|
29
|
+
// PerformanceObserver not supported for this entry type
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Track Largest Contentful Paint (LCP)
|
|
33
|
+
try {
|
|
34
|
+
let lcpValue = 0;
|
|
35
|
+
const lcpObserver = new PerformanceObserver((list) => {
|
|
36
|
+
const entries = list.getEntries();
|
|
37
|
+
const lastEntry = entries[entries.length - 1];
|
|
38
|
+
if (lastEntry) {
|
|
39
|
+
lcpValue = lastEntry.startTime;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
43
|
+
|
|
44
|
+
// Report LCP when page becomes hidden
|
|
45
|
+
document.addEventListener(
|
|
46
|
+
'visibilitychange',
|
|
47
|
+
() => {
|
|
48
|
+
if (document.visibilityState === 'hidden' && lcpValue > 0) {
|
|
49
|
+
const event = createBaseEvent('web_vital');
|
|
50
|
+
event.event_name = 'lcp';
|
|
51
|
+
event.lcp = Math.round(lcpValue);
|
|
52
|
+
queueEvent(event);
|
|
53
|
+
flushEvents();
|
|
54
|
+
lcpObserver.disconnect();
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
{ once: true }
|
|
58
|
+
);
|
|
59
|
+
} catch {
|
|
60
|
+
// PerformanceObserver not supported for this entry type
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Track Cumulative Layout Shift (CLS)
|
|
64
|
+
try {
|
|
65
|
+
let clsValue = 0;
|
|
66
|
+
const clsObserver = new PerformanceObserver((list) => {
|
|
67
|
+
for (const entry of list.getEntries()) {
|
|
68
|
+
const layoutShift = entry as PerformanceEntry & {
|
|
69
|
+
hadRecentInput?: boolean;
|
|
70
|
+
value?: number;
|
|
71
|
+
};
|
|
72
|
+
if (!layoutShift.hadRecentInput && layoutShift.value) {
|
|
73
|
+
clsValue += layoutShift.value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
clsObserver.observe({ type: 'layout-shift', buffered: true });
|
|
78
|
+
|
|
79
|
+
// Report CLS when page becomes hidden
|
|
80
|
+
document.addEventListener(
|
|
81
|
+
'visibilitychange',
|
|
82
|
+
() => {
|
|
83
|
+
if (document.visibilityState === 'hidden') {
|
|
84
|
+
const event = createBaseEvent('web_vital');
|
|
85
|
+
event.event_name = 'cls';
|
|
86
|
+
event.cls = Math.round(clsValue * 1000) / 1000; // Round to 3 decimal places
|
|
87
|
+
queueEvent(event);
|
|
88
|
+
flushEvents();
|
|
89
|
+
clsObserver.disconnect();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{ once: true }
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
// PerformanceObserver not supported for this entry type
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Track Interaction to Next Paint (INP)
|
|
99
|
+
try {
|
|
100
|
+
let inpValue = 0;
|
|
101
|
+
const inpObserver = new PerformanceObserver((list) => {
|
|
102
|
+
for (const entry of list.getEntries()) {
|
|
103
|
+
const eventEntry = entry as PerformanceEntry & { duration?: number };
|
|
104
|
+
if (eventEntry.duration && eventEntry.duration > inpValue) {
|
|
105
|
+
inpValue = eventEntry.duration;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
inpObserver.observe({ type: 'event', buffered: true });
|
|
110
|
+
|
|
111
|
+
// Report INP when page becomes hidden
|
|
112
|
+
document.addEventListener(
|
|
113
|
+
'visibilitychange',
|
|
114
|
+
() => {
|
|
115
|
+
if (document.visibilityState === 'hidden' && inpValue > 0) {
|
|
116
|
+
const event = createBaseEvent('web_vital');
|
|
117
|
+
event.event_name = 'inp';
|
|
118
|
+
event.inp = Math.round(inpValue);
|
|
119
|
+
queueEvent(event);
|
|
120
|
+
flushEvents();
|
|
121
|
+
inpObserver.disconnect();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{ once: true }
|
|
125
|
+
);
|
|
126
|
+
} catch {
|
|
127
|
+
// PerformanceObserver not supported for this entry type
|
|
128
|
+
}
|
|
129
|
+
}
|