@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/serialize.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { SerializedArg } from '@obsrviq/types';
|
|
2
|
+
import { maskString, maskValue } from './mask.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_BYTES = 8 * 1024; // 8 KB string cap (§6.1)
|
|
5
|
+
|
|
6
|
+
function byteLen(s: string): number {
|
|
7
|
+
return new Blob([s]).size;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function truncate(s: string, maxBytes: number): { value: string; truncated: boolean } {
|
|
11
|
+
if (byteLen(s) <= maxBytes) return { value: s, truncated: false };
|
|
12
|
+
// Cheap char-based cut; good enough for previews.
|
|
13
|
+
const approx = Math.max(16, Math.floor(maxBytes * 0.95));
|
|
14
|
+
return { value: s.slice(0, approx), truncated: true };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Serialize a single console/track argument into a SerializedArg (§4, §6.1). */
|
|
18
|
+
export function serializeArg(arg: unknown, maxBytes = DEFAULT_MAX_BYTES): SerializedArg {
|
|
19
|
+
if (arg === null) return { kind: 'primitive', value: null };
|
|
20
|
+
const type = typeof arg;
|
|
21
|
+
|
|
22
|
+
if (type === 'number' || type === 'boolean') {
|
|
23
|
+
return { kind: 'primitive', value: arg as number | boolean };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (type === 'string') {
|
|
27
|
+
const masked = maskString(arg as string);
|
|
28
|
+
const { value, truncated } = truncate(masked, maxBytes);
|
|
29
|
+
return { kind: 'string', value, truncated };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (arg instanceof Error) {
|
|
33
|
+
return {
|
|
34
|
+
kind: 'error',
|
|
35
|
+
name: arg.name,
|
|
36
|
+
message: maskString(arg.message),
|
|
37
|
+
stack: arg.stack ? maskString(arg.stack) : undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (arg instanceof Element) {
|
|
42
|
+
return { kind: 'dom', selector: cssPath(arg) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (type === 'object' || type === 'function' || type === 'symbol' || type === 'bigint') {
|
|
46
|
+
const masked = maskValue(arg);
|
|
47
|
+
let json: string | undefined;
|
|
48
|
+
let truncated = false;
|
|
49
|
+
try {
|
|
50
|
+
const full = JSON.stringify(masked, safeReplacer());
|
|
51
|
+
const cut = truncate(full ?? 'undefined', maxBytes);
|
|
52
|
+
json = cut.value;
|
|
53
|
+
truncated = cut.truncated;
|
|
54
|
+
} catch {
|
|
55
|
+
json = undefined;
|
|
56
|
+
}
|
|
57
|
+
return { kind: 'object', preview: previewOf(masked), json, truncated };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// undefined / fallback
|
|
61
|
+
return { kind: 'primitive', value: String(arg) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function safeReplacer() {
|
|
65
|
+
const seen = new WeakSet();
|
|
66
|
+
return (_key: string, value: unknown) => {
|
|
67
|
+
if (typeof value === 'bigint') return `${value.toString()}n`;
|
|
68
|
+
if (typeof value === 'function') return `ƒ ${(value as { name?: string }).name || 'anonymous'}`;
|
|
69
|
+
if (typeof value === 'object' && value !== null) {
|
|
70
|
+
if (seen.has(value)) return '«circular»';
|
|
71
|
+
seen.add(value);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function previewOf(value: unknown): string {
|
|
78
|
+
if (Array.isArray(value)) return `Array(${value.length})`;
|
|
79
|
+
if (value && typeof value === 'object') {
|
|
80
|
+
const keys = Object.keys(value as object);
|
|
81
|
+
const head = keys.slice(0, 4).join(', ');
|
|
82
|
+
return `{ ${head}${keys.length > 4 ? ', …' : ''} }`;
|
|
83
|
+
}
|
|
84
|
+
return String(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A short, stable CSS-ish selector for a DOM node (no PII). */
|
|
88
|
+
export function cssPath(el: Element): string {
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
let node: Element | null = el;
|
|
91
|
+
let depth = 0;
|
|
92
|
+
while (node && node.nodeType === 1 && depth < 5) {
|
|
93
|
+
let part = node.tagName.toLowerCase();
|
|
94
|
+
if (node.id) {
|
|
95
|
+
part += `#${node.id}`;
|
|
96
|
+
parts.unshift(part);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean)[0];
|
|
100
|
+
if (cls) part += `.${cls}`;
|
|
101
|
+
parts.unshift(part);
|
|
102
|
+
node = node.parentElement;
|
|
103
|
+
depth++;
|
|
104
|
+
}
|
|
105
|
+
return parts.join(' > ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parse the top frame of a stack into a source location. */
|
|
109
|
+
export function parseSource(stack?: string): { url?: string; line?: number; col?: number } | undefined {
|
|
110
|
+
if (!stack) return undefined;
|
|
111
|
+
const lines = stack.split('\n').map((l) => l.trim());
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
const m =
|
|
114
|
+
line.match(/\((https?:\/\/[^)]+):(\d+):(\d+)\)/) ||
|
|
115
|
+
line.match(/at (https?:\/\/\S+):(\d+):(\d+)/) ||
|
|
116
|
+
line.match(/(https?:\/\/\S+):(\d+):(\d+)/);
|
|
117
|
+
if (m) return { url: m[1], line: Number(m[2]), col: Number(m[3]) };
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returning-visitor tracking.
|
|
3
|
+
*
|
|
4
|
+
* A single persistent, ANONYMOUS visitor record in localStorage (no PII — just a
|
|
5
|
+
* random id + counters) lets the backend tell first-time from returning visitors
|
|
6
|
+
* and stitch a person's repeat visits together. Written only after consent (the
|
|
7
|
+
* caller gates this), and fully guarded for SSR / private-mode / disabled storage
|
|
8
|
+
* — visitor tracking silently no-ops rather than ever throwing.
|
|
9
|
+
*/
|
|
10
|
+
import { uuid } from './util.js';
|
|
11
|
+
|
|
12
|
+
const VISITOR_KEY = 'obsrviq.visitor';
|
|
13
|
+
const SESSION_KEY = 'obsrviq.session';
|
|
14
|
+
|
|
15
|
+
/** Persisted across page loads so a returning visit within the inactivity window
|
|
16
|
+
* can RESUME the same session (opt-in `continueSession`). Same-origin localStorage
|
|
17
|
+
* only — graceful no-op when unavailable (→ per-page sessions, today's default). */
|
|
18
|
+
export interface SessionRecord {
|
|
19
|
+
/** The session id to continue. */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Epoch ms the (original) session started — the stable clock anchor. */
|
|
22
|
+
startedAt: number;
|
|
23
|
+
/** Epoch ms of the last activity (flush) — the inactivity-gap reference. */
|
|
24
|
+
lastActivity: number;
|
|
25
|
+
/** Next batch seq to send (kept monotonic across page loads → no chunk-key
|
|
26
|
+
* collision on the (session_id, seq) primary key). */
|
|
27
|
+
seq: number;
|
|
28
|
+
/** Highest event `t` (ms since session start) reached so far — used as a floor
|
|
29
|
+
* for the resumed clock so the next page's timestamps never overlap the prior
|
|
30
|
+
* page's, even if the wall clock moves backward between loads. */
|
|
31
|
+
lastT?: number;
|
|
32
|
+
/** The userId this session is attributed to, if any — so a DIFFERENT user on a
|
|
33
|
+
* shared device starts a fresh session instead of merging. */
|
|
34
|
+
userId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readSession(): SessionRecord | null {
|
|
38
|
+
const raw = readLS(SESSION_KEY);
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
try {
|
|
41
|
+
const p = JSON.parse(raw) as Partial<SessionRecord>;
|
|
42
|
+
if (p && typeof p.id === 'string' && p.id && typeof p.startedAt === 'number') {
|
|
43
|
+
return {
|
|
44
|
+
id: p.id,
|
|
45
|
+
startedAt: p.startedAt,
|
|
46
|
+
lastActivity: typeof p.lastActivity === 'number' ? p.lastActivity : p.startedAt,
|
|
47
|
+
seq: typeof p.seq === 'number' && p.seq >= 0 ? p.seq : 0,
|
|
48
|
+
lastT: typeof p.lastT === 'number' && p.lastT >= 0 ? p.lastT : undefined,
|
|
49
|
+
userId: typeof p.userId === 'string' ? p.userId : undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
/* corrupted — treat as none */
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function writeSession(rec: SessionRecord): void {
|
|
59
|
+
writeLS(SESSION_KEY, JSON.stringify(rec));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function clearSession(): void {
|
|
63
|
+
if (typeof window === 'undefined') return;
|
|
64
|
+
try {
|
|
65
|
+
window.localStorage.removeItem(SESSION_KEY);
|
|
66
|
+
} catch {
|
|
67
|
+
/* ignore */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface VisitorRecord {
|
|
72
|
+
/** Stable anonymous id for this browser. */
|
|
73
|
+
id: string;
|
|
74
|
+
/** Epoch ms first ever seen. */
|
|
75
|
+
firstSeenAt: number;
|
|
76
|
+
/** Epoch ms of the most recent visit (this one, after registerVisit). */
|
|
77
|
+
lastSeenAt: number;
|
|
78
|
+
/** Lifetime visit count, including the current visit. */
|
|
79
|
+
visits: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readLS(key: string): string | null {
|
|
83
|
+
if (typeof window === 'undefined') return null;
|
|
84
|
+
try {
|
|
85
|
+
return window.localStorage.getItem(key);
|
|
86
|
+
} catch {
|
|
87
|
+
return null; // private mode / disabled / sandboxed
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeLS(key: string, value: string): void {
|
|
92
|
+
if (typeof window === 'undefined') return;
|
|
93
|
+
try {
|
|
94
|
+
window.localStorage.setItem(key, value);
|
|
95
|
+
} catch {
|
|
96
|
+
/* quota / disabled — visitor tracking degrades to per-session */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read-or-create the visitor record and register the current visit (increments
|
|
102
|
+
* the count, stamps lastSeenAt). Returns the snapshot for THIS session: a fresh
|
|
103
|
+
* record has visits === 1 (first-time visitor); an existing one has visits > 1
|
|
104
|
+
* (returning). Never throws.
|
|
105
|
+
*/
|
|
106
|
+
export function registerVisit(now: number): VisitorRecord {
|
|
107
|
+
let rec: VisitorRecord | null = null;
|
|
108
|
+
const raw = readLS(VISITOR_KEY);
|
|
109
|
+
if (raw) {
|
|
110
|
+
try {
|
|
111
|
+
const p = JSON.parse(raw) as Partial<VisitorRecord>;
|
|
112
|
+
if (p && typeof p.id === 'string' && p.id) {
|
|
113
|
+
rec = {
|
|
114
|
+
id: p.id,
|
|
115
|
+
firstSeenAt: typeof p.firstSeenAt === 'number' ? p.firstSeenAt : now,
|
|
116
|
+
lastSeenAt: typeof p.lastSeenAt === 'number' ? p.lastSeenAt : now,
|
|
117
|
+
visits: typeof p.visits === 'number' && p.visits > 0 ? p.visits : 1,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
/* corrupted JSON — fall through and regenerate */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const next: VisitorRecord = rec
|
|
126
|
+
? { ...rec, visits: rec.visits + 1, lastSeenAt: now }
|
|
127
|
+
: { id: uuid(), firstSeenAt: now, lastSeenAt: now, visits: 1 };
|
|
128
|
+
|
|
129
|
+
writeLS(VISITOR_KEY, JSON.stringify(next));
|
|
130
|
+
return next;
|
|
131
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { gzipSync } from 'fflate';
|
|
2
|
+
import type { IngestBatch, IngestSessionMeta, ObsrviqEvent } from '@obsrviq/types';
|
|
3
|
+
|
|
4
|
+
export interface TransportOptions {
|
|
5
|
+
endpoint: string;
|
|
6
|
+
siteKey: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
getMeta: () => IngestSessionMeta | undefined;
|
|
9
|
+
beforeSend?: (batch: IngestBatch) => IngestBatch | null;
|
|
10
|
+
flushIntervalMs: number;
|
|
11
|
+
/** Resume seq from here (continued session); defaults to 0. Keeps the chunk
|
|
12
|
+
* (session_id, seq) key monotonic across page loads. */
|
|
13
|
+
startSeq?: number;
|
|
14
|
+
/** Called at DISPATCH (before the network await) with the NEXT seq to use — lets
|
|
15
|
+
* the host persist progress synchronously so a later page load resumes without a
|
|
16
|
+
* seq collision even if an unload interrupts the in-flight request. */
|
|
17
|
+
onSeqDispatch?: (nextSeq: number) => void;
|
|
18
|
+
/** test/observability hook */
|
|
19
|
+
onFlush?: (info: { bytes: number; events: number; ok: boolean }) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_BUFFER_EVENTS = 5000; // backpressure cap (R-5)
|
|
23
|
+
|
|
24
|
+
export class Transport {
|
|
25
|
+
private buffer: ObsrviqEvent[] = [];
|
|
26
|
+
private seq: number;
|
|
27
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
28
|
+
private sending = false;
|
|
29
|
+
private retryDelay = 1000;
|
|
30
|
+
private stopped = false;
|
|
31
|
+
|
|
32
|
+
constructor(private opts: TransportOptions) {
|
|
33
|
+
this.seq = opts.startSeq ?? 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
start() {
|
|
37
|
+
this.timer = setInterval(() => void this.flush('timer'), this.opts.flushIntervalMs);
|
|
38
|
+
document.addEventListener('visibilitychange', this.onVisibility, { capture: true });
|
|
39
|
+
window.addEventListener('pagehide', this.onPageHide, { capture: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
enqueue(event: ObsrviqEvent) {
|
|
43
|
+
if (this.stopped) return;
|
|
44
|
+
this.buffer.push(event);
|
|
45
|
+
// Backpressure: if we've buffered a lot, flush early; if still over after a
|
|
46
|
+
// failed flush, drop the oldest DOM events (keep structured signal).
|
|
47
|
+
if (this.buffer.length >= MAX_BUFFER_EVENTS) {
|
|
48
|
+
void this.flush('backpressure');
|
|
49
|
+
if (this.buffer.length >= MAX_BUFFER_EVENTS) {
|
|
50
|
+
this.buffer = this.buffer.filter((e, i) => e.type !== 'dom' || i % 2 === 0);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build, compress, and send the current buffer. Returns true on success. */
|
|
56
|
+
async flush(reason: 'timer' | 'hidden' | 'pagehide' | 'manual' | 'backpressure'): Promise<boolean> {
|
|
57
|
+
if (this.sending || this.buffer.length === 0) return true;
|
|
58
|
+
this.sending = true;
|
|
59
|
+
const events = this.buffer;
|
|
60
|
+
this.buffer = [];
|
|
61
|
+
|
|
62
|
+
let batch: IngestBatch | null = {
|
|
63
|
+
siteKey: this.opts.siteKey,
|
|
64
|
+
sessionId: this.opts.sessionId,
|
|
65
|
+
seq: this.seq,
|
|
66
|
+
meta: this.opts.getMeta(),
|
|
67
|
+
events,
|
|
68
|
+
};
|
|
69
|
+
if (this.opts.beforeSend) {
|
|
70
|
+
try {
|
|
71
|
+
batch = this.opts.beforeSend(batch);
|
|
72
|
+
} catch {
|
|
73
|
+
batch = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!batch) {
|
|
77
|
+
this.sending = false;
|
|
78
|
+
return true; // dropped intentionally
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Reserve the next seq + stamp activity SYNCHRONOUSLY at dispatch — before any
|
|
82
|
+
// network await. This is what session continuation persists, so an unload that
|
|
83
|
+
// interrupts the in-flight request can never let the next page re-use this seq
|
|
84
|
+
// (which would overwrite a chunk). It also means lastActivity reflects real
|
|
85
|
+
// dispatch attempts even when the network is down and sends keep failing — so an
|
|
86
|
+
// active-but-offline session isn't wrongly treated as idle and split.
|
|
87
|
+
this.opts.onSeqDispatch?.(this.seq + 1);
|
|
88
|
+
|
|
89
|
+
const json = JSON.stringify(batch);
|
|
90
|
+
const gz = gzipSync(new TextEncoder().encode(json));
|
|
91
|
+
const ok = await this.send(gz, reason === 'pagehide' || reason === 'hidden');
|
|
92
|
+
this.opts.onFlush?.({ bytes: gz.byteLength, events: events.length, ok });
|
|
93
|
+
|
|
94
|
+
if (ok) {
|
|
95
|
+
this.seq++;
|
|
96
|
+
this.retryDelay = 1000;
|
|
97
|
+
this.sending = false;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Failed: re-buffer (capped) and retry with jitter.
|
|
102
|
+
this.buffer = events.slice(-MAX_BUFFER_EVENTS).concat(this.buffer);
|
|
103
|
+
this.sending = false;
|
|
104
|
+
if (!this.stopped) {
|
|
105
|
+
const jitter = this.retryDelay * (0.5 + Math.random());
|
|
106
|
+
this.retryDelay = Math.min(this.retryDelay * 2, 30_000);
|
|
107
|
+
setTimeout(() => void this.flush('timer'), jitter);
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async send(bytes: Uint8Array, unloading: boolean): Promise<boolean> {
|
|
113
|
+
const url = `${this.opts.endpoint.replace(/\/$/, '')}/v1/batch`;
|
|
114
|
+
const headers = {
|
|
115
|
+
'content-type': 'application/octet-stream',
|
|
116
|
+
'x-obsrviq-key': this.opts.siteKey,
|
|
117
|
+
};
|
|
118
|
+
const body = bytes as unknown as BodyInit;
|
|
119
|
+
|
|
120
|
+
// Unload path: a normal fetch would be cancelled as the page goes away, so
|
|
121
|
+
// use sendBeacon (survives navigation) or a keepalive fetch. BOTH cap the
|
|
122
|
+
// body at ~64KB, so large (snapshot) batches are best-effort here — but the
|
|
123
|
+
// periodic flushes below send them reliably during normal operation.
|
|
124
|
+
if (unloading) {
|
|
125
|
+
const blob = new Blob([bytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
|
126
|
+
if (typeof navigator.sendBeacon === 'function') {
|
|
127
|
+
try {
|
|
128
|
+
if (navigator.sendBeacon(url, blob)) return true;
|
|
129
|
+
} catch {
|
|
130
|
+
/* fall through */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
void fetch(url, { method: 'POST', body, headers, keepalive: true, credentials: 'omit', mode: 'cors' }).catch(
|
|
135
|
+
() => {},
|
|
136
|
+
);
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Normal flush: a plain fetch — NO `keepalive`, so there's no 64KB body cap
|
|
144
|
+
// and large FullSnapshot batches actually send. Await the real result so a
|
|
145
|
+
// failure (rejected / 4xx / 5xx / network) re-buffers and retries instead of
|
|
146
|
+
// being silently dropped. A timeout aborts a hung request so it can't stall
|
|
147
|
+
// all future flushes (`sending` stays true until this resolves).
|
|
148
|
+
const ctrl = new AbortController();
|
|
149
|
+
const timeout = setTimeout(() => ctrl.abort(), 15_000);
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch(url, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
body,
|
|
154
|
+
headers,
|
|
155
|
+
credentials: 'omit',
|
|
156
|
+
mode: 'cors',
|
|
157
|
+
signal: ctrl.signal,
|
|
158
|
+
});
|
|
159
|
+
return res.ok;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timeout);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private onVisibility = () => {
|
|
168
|
+
if (document.visibilityState === 'hidden') void this.flush('hidden');
|
|
169
|
+
};
|
|
170
|
+
private onPageHide = () => {
|
|
171
|
+
void this.flush('pagehide');
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
stop() {
|
|
175
|
+
this.stopped = true;
|
|
176
|
+
if (this.timer) clearInterval(this.timer);
|
|
177
|
+
document.removeEventListener('visibilitychange', this.onVisibility, { capture: true });
|
|
178
|
+
window.removeEventListener('pagehide', this.onPageHide, { capture: true });
|
|
179
|
+
void this.flush('manual');
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained UI the tracker injects into the host page:
|
|
3
|
+
* - showConsent() — a corner consent card (askPermission)
|
|
4
|
+
* - showRecordingDot() — a minimal pulsing "recording" dot (showRecording)
|
|
5
|
+
*
|
|
6
|
+
* Isolation rules (the SDK is otherwise headless):
|
|
7
|
+
* - attachShadow({mode:'open'}) + adoptedStyleSheets → no host CSS bleed and
|
|
8
|
+
* CSP-safe (no inline <style>, so `style-src 'self'` pages don't block it).
|
|
9
|
+
* - the host element wears the recorder's block class (default `.obsrviq-block`)
|
|
10
|
+
* so the SDK's own rrweb recorder never captures its own UI. The host box is
|
|
11
|
+
* zero-size (its children are position:fixed), so no placeholder shows in the
|
|
12
|
+
* replay.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ConsentText {
|
|
16
|
+
title?: string;
|
|
17
|
+
body?: string;
|
|
18
|
+
allow?: string;
|
|
19
|
+
deny?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Derive a class the host can wear so the recorder's blockSelector excludes it.
|
|
23
|
+
* blockSelector is usually a class selector (default '.obsrviq-block'). We always
|
|
24
|
+
* keep `obsrviq-block` too, so a custom blockSelector can't accidentally un-hide. */
|
|
25
|
+
function blockClassFrom(blockSelector: string): string {
|
|
26
|
+
const m = /\.([A-Za-z0-9_-]+)/.exec(blockSelector || '');
|
|
27
|
+
const derived = m && m[1] ? m[1] : '';
|
|
28
|
+
return ['obsrviq-block', derived].filter(Boolean).join(' ');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeHost(blockSelector: string): { host: HTMLElement; root: ShadowRoot } {
|
|
32
|
+
const host = document.createElement('div');
|
|
33
|
+
host.className = blockClassFrom(blockSelector);
|
|
34
|
+
host.setAttribute('data-obsrviq-ui', '');
|
|
35
|
+
host.style.cssText = 'all:initial';
|
|
36
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
37
|
+
return { host, root };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function adopt(root: ShadowRoot, css: string): void {
|
|
41
|
+
try {
|
|
42
|
+
const sheet = new CSSStyleSheet();
|
|
43
|
+
sheet.replaceSync(css);
|
|
44
|
+
root.adoptedStyleSheets = [sheet];
|
|
45
|
+
} catch {
|
|
46
|
+
// Engines without constructable stylesheets — inline <style> inside the shadow
|
|
47
|
+
// (still isolated; only a concern on host pages with a strict style-src CSP).
|
|
48
|
+
const style = document.createElement('style');
|
|
49
|
+
style.textContent = css;
|
|
50
|
+
root.appendChild(style);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mount(host: HTMLElement): void {
|
|
55
|
+
(document.body || document.documentElement).appendChild(host);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ACCENT = '#6e76f1';
|
|
59
|
+
|
|
60
|
+
const CONSENT_CSS = `
|
|
61
|
+
:host { all: initial; }
|
|
62
|
+
.card {
|
|
63
|
+
position: fixed; bottom: 20px; right: 20px; z-index: 2147483647;
|
|
64
|
+
max-width: 340px; box-sizing: border-box;
|
|
65
|
+
display: grid; grid-template-columns: auto 1fr;
|
|
66
|
+
grid-template-areas: 'dot txt' 'actions actions'; gap: 9px 12px; padding: 16px 18px;
|
|
67
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
68
|
+
color: #f4f5fb; background: #14151b; border: 1px solid rgba(255,255,255,.08);
|
|
69
|
+
border-radius: 14px; box-shadow: 0 16px 48px rgba(0,0,0,.5);
|
|
70
|
+
opacity: 0; transform: translateY(10px); transition: opacity .22s ease, transform .22s ease;
|
|
71
|
+
}
|
|
72
|
+
.card.in { opacity: 1; transform: none; }
|
|
73
|
+
.dot { grid-area: dot; width: 9px; height: 9px; margin-top: 5px; border-radius: 50%;
|
|
74
|
+
background: ${ACCENT}; box-shadow: 0 0 0 4px rgba(110,118,241,.18); }
|
|
75
|
+
.txt { grid-area: txt; min-width: 0; }
|
|
76
|
+
.title { font-size: 13.5px; font-weight: 600; line-height: 1.35; }
|
|
77
|
+
.body { margin-top: 3px; font-size: 12px; line-height: 1.45; color: #a9adbe; }
|
|
78
|
+
.actions { grid-area: actions; display: flex; gap: 8px; justify-content: flex-end; margin-top: 2px; }
|
|
79
|
+
button { font: inherit; font-size: 12.5px; font-weight: 600; padding: 7px 15px; border-radius: 9px;
|
|
80
|
+
cursor: pointer; border: 1px solid transparent; transition: background .15s ease, color .15s ease; }
|
|
81
|
+
.deny { background: transparent; color: #a9adbe; border-color: rgba(255,255,255,.14); }
|
|
82
|
+
.deny:hover { color: #f4f5fb; background: rgba(255,255,255,.05); }
|
|
83
|
+
.allow { background: ${ACCENT}; color: #fff; }
|
|
84
|
+
.allow:hover { background: #5a62e0; }
|
|
85
|
+
button:focus-visible { outline: 2px solid ${ACCENT}; outline-offset: 2px; }
|
|
86
|
+
@media (prefers-reduced-motion: reduce) { .card { transition: none; } }
|
|
87
|
+
@media (max-width: 480px) { .card { left: 16px; right: 16px; bottom: 16px; max-width: none; } }
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
const DOT_CSS = `
|
|
91
|
+
:host { all: initial; }
|
|
92
|
+
.recdot {
|
|
93
|
+
position: fixed; bottom: 16px; right: 16px; z-index: 2147483647;
|
|
94
|
+
width: 12px; height: 12px; border-radius: 50%;
|
|
95
|
+
background: #ef4444; box-shadow: 0 0 0 0 rgba(239,68,68,.5);
|
|
96
|
+
animation: lum-pulse 2s ease-out infinite;
|
|
97
|
+
}
|
|
98
|
+
@keyframes lum-pulse {
|
|
99
|
+
0% { box-shadow: 0 0 0 0 rgba(239,68,68,.5); }
|
|
100
|
+
70% { box-shadow: 0 0 0 9px rgba(239,68,68,0); }
|
|
101
|
+
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
|
|
102
|
+
}
|
|
103
|
+
@media (prefers-reduced-motion: reduce) { .recdot { animation: none; } }
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
/** Corner consent card. Resolves via onAllow/onDeny exactly once, then unmounts.
|
|
107
|
+
* Returns an unmount fn (e.g. to tear down if consent is set programmatically). */
|
|
108
|
+
export function showConsent(
|
|
109
|
+
blockSelector: string,
|
|
110
|
+
text: ConsentText | undefined,
|
|
111
|
+
onAllow: () => void,
|
|
112
|
+
onDeny: () => void,
|
|
113
|
+
): () => void {
|
|
114
|
+
const { host, root } = makeHost(blockSelector);
|
|
115
|
+
adopt(root, CONSENT_CSS);
|
|
116
|
+
const t = text ?? {};
|
|
117
|
+
|
|
118
|
+
const card = document.createElement('div');
|
|
119
|
+
card.className = 'card';
|
|
120
|
+
card.setAttribute('role', 'dialog');
|
|
121
|
+
card.setAttribute('aria-label', t.title || 'Session recording consent');
|
|
122
|
+
|
|
123
|
+
const dot = document.createElement('div');
|
|
124
|
+
dot.className = 'dot';
|
|
125
|
+
dot.setAttribute('aria-hidden', 'true');
|
|
126
|
+
|
|
127
|
+
const txt = document.createElement('div');
|
|
128
|
+
txt.className = 'txt';
|
|
129
|
+
const title = document.createElement('div');
|
|
130
|
+
title.className = 'title';
|
|
131
|
+
title.textContent = t.title || 'We record this session to improve the experience.';
|
|
132
|
+
const body = document.createElement('div');
|
|
133
|
+
body.className = 'body';
|
|
134
|
+
body.textContent = t.body || 'No passwords or typed input are captured.';
|
|
135
|
+
txt.append(title, body);
|
|
136
|
+
|
|
137
|
+
const actions = document.createElement('div');
|
|
138
|
+
actions.className = 'actions';
|
|
139
|
+
const deny = document.createElement('button');
|
|
140
|
+
deny.type = 'button';
|
|
141
|
+
deny.className = 'deny';
|
|
142
|
+
deny.textContent = t.deny || 'Decline';
|
|
143
|
+
const allow = document.createElement('button');
|
|
144
|
+
allow.type = 'button';
|
|
145
|
+
allow.className = 'allow';
|
|
146
|
+
allow.textContent = t.allow || 'Allow';
|
|
147
|
+
actions.append(deny, allow);
|
|
148
|
+
|
|
149
|
+
card.append(dot, txt, actions);
|
|
150
|
+
root.appendChild(card);
|
|
151
|
+
mount(host);
|
|
152
|
+
requestAnimationFrame(() => card.classList.add('in'));
|
|
153
|
+
|
|
154
|
+
let done = false;
|
|
155
|
+
const unmount = () => {
|
|
156
|
+
if (host.parentNode) host.parentNode.removeChild(host);
|
|
157
|
+
};
|
|
158
|
+
const choose = (fn: () => void) => () => {
|
|
159
|
+
if (done) return;
|
|
160
|
+
done = true;
|
|
161
|
+
unmount();
|
|
162
|
+
fn();
|
|
163
|
+
};
|
|
164
|
+
allow.addEventListener('click', choose(onAllow));
|
|
165
|
+
deny.addEventListener('click', choose(onDeny));
|
|
166
|
+
return unmount;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Minimal pulsing "recording" dot (bottom-right). Returns an unmount fn. */
|
|
170
|
+
export function showRecordingDot(blockSelector: string): () => void {
|
|
171
|
+
const { host, root } = makeHost(blockSelector);
|
|
172
|
+
adopt(root, DOT_CSS);
|
|
173
|
+
const dot = document.createElement('div');
|
|
174
|
+
dot.className = 'recdot';
|
|
175
|
+
dot.setAttribute('role', 'status');
|
|
176
|
+
dot.setAttribute('aria-label', 'Session recording in progress');
|
|
177
|
+
root.appendChild(dot);
|
|
178
|
+
mount(host);
|
|
179
|
+
return () => {
|
|
180
|
+
if (host.parentNode) host.parentNode.removeChild(host);
|
|
181
|
+
};
|
|
182
|
+
}
|