@obsrviq/tracker 0.3.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/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +1827 -0
- package/dist/index.js.map +1 -0
- package/dist/obsrviq.global.js +189 -0
- package/dist/obsrviq.global.js.map +1 -0
- package/package.json +41 -0
- package/src/console.ts +96 -0
- package/src/context.ts +44 -0
- package/src/errors.ts +78 -0
- package/src/forms.ts +189 -0
- package/src/global.ts +61 -0
- package/src/index.ts +436 -0
- package/src/interactions.ts +70 -0
- package/src/mask.ts +42 -0
- package/src/network.ts +439 -0
- package/src/pageviews.ts +67 -0
- package/src/recorder.ts +53 -0
- package/src/scroll.ts +65 -0
- package/src/serialize.ts +120 -0
- package/src/storage.ts +131 -0
- package/src/transport.ts +181 -0
- package/src/ui.ts +182 -0
- package/src/util.ts +96 -0
- package/src/vitals.ts +111 -0
package/src/util.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Small browser utilities shared across the SDK.
|
|
2
|
+
|
|
3
|
+
export function uuid(): string {
|
|
4
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
5
|
+
return crypto.randomUUID();
|
|
6
|
+
}
|
|
7
|
+
// RFC4122-ish fallback.
|
|
8
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
9
|
+
const r = (Math.random() * 16) | 0;
|
|
10
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
11
|
+
return v.toString(16);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A monotonic-ish session clock: ms since the session started. */
|
|
16
|
+
export class Clock {
|
|
17
|
+
readonly startedAt: number;
|
|
18
|
+
private perfStart: number;
|
|
19
|
+
/** Wall ms elapsed between the ORIGINAL session start and this page's start —
|
|
20
|
+
* added to every `t` so a resumed (continued) session's timeline keeps climbing
|
|
21
|
+
* across the inactivity gap instead of restarting at 0. 0 for a fresh session. */
|
|
22
|
+
private baseT: number;
|
|
23
|
+
/** @param resumeStartedAt epoch ms of the original session start when RESUMING a
|
|
24
|
+
* continued session; omit for a fresh session.
|
|
25
|
+
* @param floorT highest `t` the prior page reached — guarantees this page's
|
|
26
|
+
* timeline starts after it, so a backward wall-clock jump between loads can't
|
|
27
|
+
* produce overlapping timestamps (which would corrupt replay ordering). */
|
|
28
|
+
constructor(resumeStartedAt?: number, floorT?: number) {
|
|
29
|
+
const wallNow = Date.now();
|
|
30
|
+
this.startedAt = resumeStartedAt ?? wallNow;
|
|
31
|
+
this.perfStart = typeof performance !== 'undefined' ? performance.now() : 0;
|
|
32
|
+
this.baseT = Math.max(0, wallNow - this.startedAt, floorT ?? 0);
|
|
33
|
+
}
|
|
34
|
+
/** ms since the (original) session start — the master clock `t`. */
|
|
35
|
+
now(): number {
|
|
36
|
+
const p = typeof performance !== 'undefined' ? performance.now() : Date.now() - this.startedAt;
|
|
37
|
+
return Math.max(0, this.baseT + Math.round(p - this.perfStart));
|
|
38
|
+
}
|
|
39
|
+
/** wall-clock epoch ms for display (`ts`). */
|
|
40
|
+
wall(): number {
|
|
41
|
+
return Date.now();
|
|
42
|
+
}
|
|
43
|
+
/** Convert a performance-timeline timestamp (since timeOrigin) to session t. */
|
|
44
|
+
fromPerf(perfTs: number): number {
|
|
45
|
+
return Math.max(0, this.baseT + Math.round(perfTs - this.perfStart));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function sha256Hex(input: string): Promise<string> {
|
|
50
|
+
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
|
51
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
|
52
|
+
return Array.from(new Uint8Array(buf))
|
|
53
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
54
|
+
.join('');
|
|
55
|
+
}
|
|
56
|
+
// Non-crypto fallback (still one-way enough for an opaque id).
|
|
57
|
+
let h = 0;
|
|
58
|
+
for (let i = 0; i < input.length; i++) h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
|
|
59
|
+
return (h >>> 0).toString(16);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function detectDevice(): {
|
|
63
|
+
type: 'desktop' | 'mobile' | 'tablet';
|
|
64
|
+
os?: string;
|
|
65
|
+
browser?: string;
|
|
66
|
+
viewport: { w: number; h: number };
|
|
67
|
+
} {
|
|
68
|
+
const ua = navigator.userAgent;
|
|
69
|
+
const viewport = { w: window.innerWidth, h: window.innerHeight };
|
|
70
|
+
let type: 'desktop' | 'mobile' | 'tablet' = 'desktop';
|
|
71
|
+
if (/iPad|Tablet/i.test(ua)) type = 'tablet';
|
|
72
|
+
else if (/Mobi|Android|iPhone/i.test(ua)) type = 'mobile';
|
|
73
|
+
|
|
74
|
+
let os: string | undefined;
|
|
75
|
+
if (/Windows/i.test(ua)) os = 'Windows';
|
|
76
|
+
else if (/Mac OS X|Macintosh/i.test(ua)) os = 'macOS';
|
|
77
|
+
else if (/Android/i.test(ua)) os = 'Android';
|
|
78
|
+
else if (/iPhone|iPad|iOS/i.test(ua)) os = 'iOS';
|
|
79
|
+
else if (/Linux/i.test(ua)) os = 'Linux';
|
|
80
|
+
|
|
81
|
+
let browser: string | undefined;
|
|
82
|
+
if (/Edg\//.test(ua)) browser = 'Edge';
|
|
83
|
+
else if (/Chrome\//.test(ua)) browser = 'Chrome';
|
|
84
|
+
else if (/Firefox\//.test(ua)) browser = 'Firefox';
|
|
85
|
+
else if (/Safari\//.test(ua)) browser = 'Safari';
|
|
86
|
+
|
|
87
|
+
return { type, os, browser, viewport };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Global Privacy Control / Do Not Track. */
|
|
91
|
+
export function privacySignalsOptOut(): boolean {
|
|
92
|
+
const nav = navigator as Navigator & { globalPrivacyControl?: boolean; doNotTrack?: string };
|
|
93
|
+
if (nav.globalPrivacyControl === true) return true;
|
|
94
|
+
if (nav.doNotTrack === '1' || (window as { doNotTrack?: string }).doNotTrack === '1') return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
package/src/vitals.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { PerfEvent } from '@obsrviq/types';
|
|
2
|
+
import type { InstrumentCtx, Teardown } from './context.js';
|
|
3
|
+
import { uuid } from './util.js';
|
|
4
|
+
|
|
5
|
+
// Lightweight web-vitals capture via PerformanceObserver — no extra dependency.
|
|
6
|
+
// Emits LCP, CLS, INP (approx), FCP, TTFB, and a navigation summary (§5/§11).
|
|
7
|
+
|
|
8
|
+
function rate(metric: PerfEvent['metric'], value: number): PerfEvent['rating'] {
|
|
9
|
+
const t: Partial<Record<PerfEvent['metric'], [number, number]>> = {
|
|
10
|
+
LCP: [2500, 4000],
|
|
11
|
+
INP: [200, 500],
|
|
12
|
+
CLS: [0.1, 0.25],
|
|
13
|
+
FCP: [1800, 3000],
|
|
14
|
+
TTFB: [800, 1800],
|
|
15
|
+
};
|
|
16
|
+
const pair = t[metric];
|
|
17
|
+
if (!pair) return undefined;
|
|
18
|
+
if (value <= pair[0]) return 'good';
|
|
19
|
+
if (value <= pair[1]) return 'needs-improvement';
|
|
20
|
+
return 'poor';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function instrumentVitals(ctx: InstrumentCtx): Teardown {
|
|
24
|
+
if (typeof PerformanceObserver === 'undefined') return () => {};
|
|
25
|
+
const observers: PerformanceObserver[] = [];
|
|
26
|
+
|
|
27
|
+
const emit = (metric: PerfEvent['metric'], value: number, detail?: Record<string, number>) => {
|
|
28
|
+
const e: PerfEvent = {
|
|
29
|
+
id: uuid(),
|
|
30
|
+
sessionId: ctx.sessionId,
|
|
31
|
+
type: 'perf',
|
|
32
|
+
t: ctx.clock.now(),
|
|
33
|
+
ts: ctx.clock.wall(),
|
|
34
|
+
metric,
|
|
35
|
+
value: Math.round(value * 1000) / 1000,
|
|
36
|
+
rating: rate(metric, value),
|
|
37
|
+
detail,
|
|
38
|
+
};
|
|
39
|
+
ctx.emit(e);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const obs = (type: string, cb: (entries: PerformanceEntryList) => void, extra?: object) => {
|
|
43
|
+
try {
|
|
44
|
+
const o = new PerformanceObserver((list) => cb(list.getEntries()));
|
|
45
|
+
o.observe({ type, buffered: true, ...extra });
|
|
46
|
+
observers.push(o);
|
|
47
|
+
} catch {
|
|
48
|
+
/* unsupported */
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// FCP
|
|
53
|
+
obs('paint', (entries) => {
|
|
54
|
+
for (const e of entries) if (e.name === 'first-contentful-paint') emit('FCP', e.startTime);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// LCP — keep the largest seen.
|
|
58
|
+
let lcp = 0;
|
|
59
|
+
obs('largest-contentful-paint', (entries) => {
|
|
60
|
+
const last = entries[entries.length - 1];
|
|
61
|
+
if (last) lcp = last.startTime;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// CLS — accumulate session layout shift.
|
|
65
|
+
let cls = 0;
|
|
66
|
+
obs('layout-shift', (entries) => {
|
|
67
|
+
for (const e of entries as (PerformanceEntry & { value: number; hadRecentInput: boolean })[]) {
|
|
68
|
+
if (!e.hadRecentInput) cls += e.value;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// INP (approx via event timing — track max interaction delay).
|
|
73
|
+
let inp = 0;
|
|
74
|
+
obs(
|
|
75
|
+
'event',
|
|
76
|
+
(entries) => {
|
|
77
|
+
for (const e of entries as (PerformanceEntry & { duration: number })[]) {
|
|
78
|
+
if (e.duration > inp) inp = e.duration;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{ durationThreshold: 40 },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Navigation timing → TTFB + a navigation summary.
|
|
85
|
+
obs('navigation', (entries) => {
|
|
86
|
+
const nav = entries[0] as PerformanceNavigationTiming | undefined;
|
|
87
|
+
if (!nav) return;
|
|
88
|
+
emit('TTFB', nav.responseStart);
|
|
89
|
+
emit('navigation', nav.duration, {
|
|
90
|
+
domInteractive: Math.round(nav.domInteractive),
|
|
91
|
+
domComplete: Math.round(nav.domComplete),
|
|
92
|
+
loadEvent: Math.round(nav.loadEventEnd),
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Flush LCP/CLS/INP on the way out (their final values).
|
|
97
|
+
const flushFinal = () => {
|
|
98
|
+
if (lcp) emit('LCP', lcp);
|
|
99
|
+
emit('CLS', cls);
|
|
100
|
+
if (inp) emit('INP', inp);
|
|
101
|
+
};
|
|
102
|
+
const onHide = () => {
|
|
103
|
+
if (document.visibilityState === 'hidden') flushFinal();
|
|
104
|
+
};
|
|
105
|
+
document.addEventListener('visibilitychange', onHide, true);
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
observers.forEach((o) => o.disconnect());
|
|
109
|
+
document.removeEventListener('visibilitychange', onHide, true);
|
|
110
|
+
};
|
|
111
|
+
}
|