@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/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@obsrviq/tracker",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"description": "Obsrviq capture SDK — privacy-first DOM, console, network, error & web-vitals recording.",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"module": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"unpkg": "./dist/obsrviq.global.js",
|
|
14
|
+
"jsdelivr": "./dist/obsrviq.global.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./global": "./dist/obsrviq.global.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@rrweb/types": "2.0.1",
|
|
28
|
+
"fflate": "^0.8.2",
|
|
29
|
+
"rrweb": "2.0.1",
|
|
30
|
+
"@obsrviq/types": "0.3.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"tsup": "^8.3.5",
|
|
34
|
+
"typescript": "^5.7.2"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"dev": "tsup --watch",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/console.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ConsoleEvent, ConsoleLevel, ErrorEvent } from '@obsrviq/types';
|
|
2
|
+
import type { InstrumentCtx, Teardown } from './context.js';
|
|
3
|
+
import { serializeArg, parseSource } from './serialize.js';
|
|
4
|
+
import { uuid } from './util.js';
|
|
5
|
+
|
|
6
|
+
const LEVELS: ConsoleLevel[] = ['log', 'info', 'warn', 'error', 'debug'];
|
|
7
|
+
const MAX_PER_SEC = 100; // flood cap (§6.1)
|
|
8
|
+
|
|
9
|
+
export function instrumentConsole(ctx: InstrumentCtx): Teardown {
|
|
10
|
+
const original: Partial<Record<ConsoleLevel, (...a: unknown[]) => void>> = {};
|
|
11
|
+
let windowStart = ctx.clock.now();
|
|
12
|
+
let inWindow = 0;
|
|
13
|
+
let suppressed = 0;
|
|
14
|
+
|
|
15
|
+
function rateLimited(): boolean {
|
|
16
|
+
const now = ctx.clock.now();
|
|
17
|
+
if (now - windowStart >= 1000) {
|
|
18
|
+
if (suppressed > 0) {
|
|
19
|
+
// surface a single synthetic note that we dropped a flood
|
|
20
|
+
emitConsole('warn', [
|
|
21
|
+
{ kind: 'string', value: `Obsrviq: suppressed ${suppressed} console messages (flood cap)` },
|
|
22
|
+
]);
|
|
23
|
+
suppressed = 0;
|
|
24
|
+
}
|
|
25
|
+
windowStart = now;
|
|
26
|
+
inWindow = 0;
|
|
27
|
+
}
|
|
28
|
+
if (inWindow >= MAX_PER_SEC) {
|
|
29
|
+
suppressed++;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
inWindow++;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function emitConsole(level: ConsoleLevel, args: ConsoleEvent['args'], stack?: string) {
|
|
37
|
+
const source = parseSource(stack);
|
|
38
|
+
const e: ConsoleEvent = {
|
|
39
|
+
id: uuid(),
|
|
40
|
+
sessionId: ctx.sessionId,
|
|
41
|
+
type: 'console',
|
|
42
|
+
t: ctx.clock.now(),
|
|
43
|
+
ts: ctx.clock.wall(),
|
|
44
|
+
level,
|
|
45
|
+
args,
|
|
46
|
+
stack: level === 'error' ? stack : undefined,
|
|
47
|
+
source,
|
|
48
|
+
};
|
|
49
|
+
ctx.emit(e);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const level of LEVELS) {
|
|
53
|
+
const orig = console[level] as (...a: unknown[]) => void;
|
|
54
|
+
original[level] = orig;
|
|
55
|
+
console[level] = (...args: unknown[]) => {
|
|
56
|
+
try {
|
|
57
|
+
orig.apply(console, args);
|
|
58
|
+
} catch {
|
|
59
|
+
/* never let our wrapper break the original */
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
if (rateLimited()) return;
|
|
63
|
+
const serialized = args.map((a) => serializeArg(a, ctx.config.maxArgBytes));
|
|
64
|
+
let stack: string | undefined;
|
|
65
|
+
if (level === 'error') {
|
|
66
|
+
const errArg = args.find((a) => a instanceof Error) as Error | undefined;
|
|
67
|
+
stack = errArg?.stack ?? new Error().stack;
|
|
68
|
+
}
|
|
69
|
+
emitConsole(level, serialized, stack);
|
|
70
|
+
} catch {
|
|
71
|
+
/* swallow */
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
for (const level of LEVELS) {
|
|
78
|
+
if (original[level]) console[level] = original[level]!;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Shared helper used by errors.ts to also surface uncaught errors in console. */
|
|
84
|
+
export function errorToConsoleEvent(ctx: InstrumentCtx, err: ErrorEvent): ConsoleEvent {
|
|
85
|
+
return {
|
|
86
|
+
id: uuid(),
|
|
87
|
+
sessionId: ctx.sessionId,
|
|
88
|
+
type: 'console',
|
|
89
|
+
t: err.t,
|
|
90
|
+
ts: err.ts,
|
|
91
|
+
level: 'error',
|
|
92
|
+
args: [{ kind: 'error', name: err.name, message: err.message, stack: err.stack }],
|
|
93
|
+
stack: err.stack,
|
|
94
|
+
source: err.source,
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { IngestBatch, ObsrviqEvent } from '@obsrviq/types';
|
|
2
|
+
import type { Clock } from './util.js';
|
|
3
|
+
|
|
4
|
+
export interface ResolvedConfig {
|
|
5
|
+
siteKey: string;
|
|
6
|
+
userId?: string;
|
|
7
|
+
metadata?: Record<string, unknown>;
|
|
8
|
+
maskAllInputs: boolean;
|
|
9
|
+
maskTextSelector?: string;
|
|
10
|
+
blockSelector: string;
|
|
11
|
+
/** No-privacy capture (record input values + text verbatim). Passwords stay
|
|
12
|
+
* masked regardless via the recorder's maskInputOptions. */
|
|
13
|
+
noPrivacy: boolean;
|
|
14
|
+
captureNetworkBodies: boolean;
|
|
15
|
+
redactHeaders: string[];
|
|
16
|
+
/** Opt-in: resume the same session for a returning visit within the window. */
|
|
17
|
+
continueSession: boolean;
|
|
18
|
+
/** Inactivity window (ms) for continueSession. */
|
|
19
|
+
sessionTimeoutMs: number;
|
|
20
|
+
sampleRate: number;
|
|
21
|
+
recordCanvas: boolean;
|
|
22
|
+
endpoint: string;
|
|
23
|
+
flushIntervalMs: number;
|
|
24
|
+
requireConsent: boolean;
|
|
25
|
+
askPermission: boolean;
|
|
26
|
+
showRecording: boolean;
|
|
27
|
+
consentText?: { title?: string; body?: string; allow?: string; deny?: string };
|
|
28
|
+
beforeSend?: (batch: IngestBatch) => IngestBatch | null;
|
|
29
|
+
maxArgBytes: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InstrumentCtx {
|
|
33
|
+
clock: Clock;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
config: ResolvedConfig;
|
|
36
|
+
emit: (event: ObsrviqEvent) => void;
|
|
37
|
+
/** record that the app "did something" (DOM mutation / nav / network) — used
|
|
38
|
+
* by dead-click detection. */
|
|
39
|
+
markActivity: () => void;
|
|
40
|
+
/** the last time markActivity ran (ms since session start). */
|
|
41
|
+
lastActivityT: () => number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type Teardown = () => void;
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ErrorEvent as ObsrviqErrorEvent } from '@obsrviq/types';
|
|
2
|
+
import type { InstrumentCtx, Teardown } from './context.js';
|
|
3
|
+
import { errorToConsoleEvent } from './console.js';
|
|
4
|
+
import { maskString } from './mask.js';
|
|
5
|
+
import { parseSource } from './serialize.js';
|
|
6
|
+
import { uuid } from './util.js';
|
|
7
|
+
|
|
8
|
+
export function instrumentErrors(ctx: InstrumentCtx): Teardown {
|
|
9
|
+
function emitError(args: {
|
|
10
|
+
name: string;
|
|
11
|
+
message: string;
|
|
12
|
+
stack?: string;
|
|
13
|
+
source?: { url?: string; line?: number; col?: number };
|
|
14
|
+
handled: boolean;
|
|
15
|
+
}) {
|
|
16
|
+
const e: ObsrviqErrorEvent = {
|
|
17
|
+
id: uuid(),
|
|
18
|
+
sessionId: ctx.sessionId,
|
|
19
|
+
type: 'error',
|
|
20
|
+
t: ctx.clock.now(),
|
|
21
|
+
ts: ctx.clock.wall(),
|
|
22
|
+
name: args.name,
|
|
23
|
+
message: maskString(args.message),
|
|
24
|
+
stack: args.stack ? maskString(args.stack) : undefined,
|
|
25
|
+
source: args.source,
|
|
26
|
+
handled: args.handled,
|
|
27
|
+
};
|
|
28
|
+
ctx.emit(e);
|
|
29
|
+
// Surface uncaught errors in the Console panel too (§9 C-6).
|
|
30
|
+
ctx.emit(errorToConsoleEvent(ctx, e));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const onError = (ev: ErrorEvent) => {
|
|
34
|
+
const err = ev.error as Error | undefined;
|
|
35
|
+
emitError({
|
|
36
|
+
name: err?.name || 'Error',
|
|
37
|
+
message: err?.message || ev.message || 'Unknown error',
|
|
38
|
+
stack: err?.stack,
|
|
39
|
+
source: { url: ev.filename, line: ev.lineno, col: ev.colno },
|
|
40
|
+
handled: false,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const onRejection = (ev: PromiseRejectionEvent) => {
|
|
45
|
+
const reason = ev.reason;
|
|
46
|
+
if (reason instanceof Error) {
|
|
47
|
+
emitError({
|
|
48
|
+
name: reason.name,
|
|
49
|
+
message: reason.message,
|
|
50
|
+
stack: reason.stack,
|
|
51
|
+
source: parseSource(reason.stack),
|
|
52
|
+
handled: false,
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
emitError({
|
|
56
|
+
name: 'UnhandledRejection',
|
|
57
|
+
message: typeof reason === 'string' ? reason : safeStringify(reason),
|
|
58
|
+
handled: false,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
window.addEventListener('error', onError, true);
|
|
64
|
+
window.addEventListener('unhandledrejection', onRejection, true);
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
window.removeEventListener('error', onError, true);
|
|
68
|
+
window.removeEventListener('unhandledrejection', onRejection, true);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeStringify(v: unknown): string {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.stringify(v) ?? String(v);
|
|
75
|
+
} catch {
|
|
76
|
+
return String(v);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/forms.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { CustomEvent as ObsrviqCustomEvent } from '@obsrviq/types';
|
|
2
|
+
import type { InstrumentCtx, Teardown } from './context.js';
|
|
3
|
+
import { cssPath } from './serialize.js';
|
|
4
|
+
import { uuid } from './util.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Form-field analytics. Watches every form control (input / textarea / select)
|
|
8
|
+
* and, on each blur, emits a lightweight `form_field` custom event describing
|
|
9
|
+
* the *interaction* — never the value:
|
|
10
|
+
*
|
|
11
|
+
* { form, field, type, ms, changed, filled }
|
|
12
|
+
*
|
|
13
|
+
* One event per focus→blur, carrying that focus's duration (`ms`). The server
|
|
14
|
+
* sums `ms` across events for total time-on-field, counts events per session
|
|
15
|
+
* for re-entries, and reads `filled` to find fields people leave blank. A
|
|
16
|
+
* `form_submit` event is emitted when a form is submitted; the last field
|
|
17
|
+
* touched in a session whose form never submitted is the drop-off point.
|
|
18
|
+
*
|
|
19
|
+
* Privacy: values are never read. `filled` is a boolean derived from value
|
|
20
|
+
* length (or `checked`); the field key comes from structural attributes
|
|
21
|
+
* (name / id / label), exactly as form-analytics tools do. Controls inside a
|
|
22
|
+
* blocked region (the configured block selector) are skipped entirely.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const TRACKED = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
26
|
+
// Non-data inputs: buttons and hidden/file fields carry no fill behaviour worth
|
|
27
|
+
// measuring (and file is privacy-adjacent), so they're left out.
|
|
28
|
+
const SKIP_INPUT_TYPES = new Set(['hidden', 'submit', 'button', 'reset', 'image', 'file']);
|
|
29
|
+
const MAX_KEY = 80;
|
|
30
|
+
const MAX_FOCUS_MS = 300_000; // cap idle-inflated focus time (walked-away tab)
|
|
31
|
+
const MIN_FOCUS_MS = 100; // ignore sub-100ms programmatic focus blips
|
|
32
|
+
|
|
33
|
+
type FormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
|
34
|
+
|
|
35
|
+
function isTracked(el: EventTarget | null): el is FormControl {
|
|
36
|
+
if (!(el instanceof HTMLElement) || !TRACKED.has(el.tagName)) return false;
|
|
37
|
+
if (el instanceof HTMLInputElement && SKIP_INPUT_TYPES.has(el.type)) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function controlType(el: FormControl): string {
|
|
42
|
+
if (el instanceof HTMLTextAreaElement) return 'textarea';
|
|
43
|
+
if (el instanceof HTMLSelectElement) return 'select';
|
|
44
|
+
return el.type || 'text';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Non-empty / checked — a boolean only, the value itself is never read. */
|
|
48
|
+
function isFilled(el: FormControl): boolean {
|
|
49
|
+
if (el instanceof HTMLInputElement) {
|
|
50
|
+
if (el.type === 'checkbox' || el.type === 'radio') return el.checked;
|
|
51
|
+
return el.value.trim().length > 0;
|
|
52
|
+
}
|
|
53
|
+
if (el instanceof HTMLSelectElement) return el.value !== '';
|
|
54
|
+
return el.value.trim().length > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clip(s: string): string {
|
|
58
|
+
const t = s.trim().replace(/\s+/g, ' ');
|
|
59
|
+
return t.length > MAX_KEY ? t.slice(0, MAX_KEY) : t;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A stable, human-readable field identity from structural attributes only. */
|
|
63
|
+
function fieldKey(el: FormControl): string {
|
|
64
|
+
const name = el.getAttribute('name');
|
|
65
|
+
if (name) return clip(name);
|
|
66
|
+
if (el.id) return clip(el.id);
|
|
67
|
+
const aria = el.getAttribute('aria-label');
|
|
68
|
+
if (aria) return clip(aria);
|
|
69
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
70
|
+
if (labelledby) {
|
|
71
|
+
const lbl = labelledby.split(/\s+/).map((id) => el.ownerDocument.getElementById(id)?.textContent || '').join(' ').trim();
|
|
72
|
+
if (lbl) return clip(lbl);
|
|
73
|
+
}
|
|
74
|
+
if (el.labels && el.labels.length) {
|
|
75
|
+
const lbl = el.labels[0]!.textContent?.trim();
|
|
76
|
+
if (lbl) return clip(lbl);
|
|
77
|
+
}
|
|
78
|
+
const ph = el.getAttribute('placeholder');
|
|
79
|
+
if (ph) return clip(ph);
|
|
80
|
+
return clip(cssPath(el));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A stable identity for the owning form (or a per-page bucket for loose fields). */
|
|
84
|
+
function formKey(el: FormControl): string {
|
|
85
|
+
const form = el.form;
|
|
86
|
+
if (form) {
|
|
87
|
+
if (form.getAttribute('name')) return clip(form.getAttribute('name')!);
|
|
88
|
+
if (form.id) return clip(form.id);
|
|
89
|
+
const aria = form.getAttribute('aria-label');
|
|
90
|
+
if (aria) return clip(aria);
|
|
91
|
+
const action = form.getAttribute('action');
|
|
92
|
+
if (action) {
|
|
93
|
+
try {
|
|
94
|
+
return clip(new URL(action, location.href).pathname);
|
|
95
|
+
} catch {
|
|
96
|
+
return clip(action);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return clip(cssPath(form));
|
|
100
|
+
}
|
|
101
|
+
// Form-less controls (common in SPAs) bucket by page path.
|
|
102
|
+
return clip(`page:${location.pathname}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function instrumentForms(ctx: InstrumentCtx): Teardown {
|
|
106
|
+
const block = ctx.config.blockSelector;
|
|
107
|
+
// Per-focus state for the currently-focused control.
|
|
108
|
+
let focused: { el: FormControl; start: number; changed: boolean } | null = null;
|
|
109
|
+
|
|
110
|
+
const emit = (name: string, props: Record<string, unknown>) => {
|
|
111
|
+
const e: ObsrviqCustomEvent = {
|
|
112
|
+
id: uuid(),
|
|
113
|
+
sessionId: ctx.sessionId,
|
|
114
|
+
type: 'custom',
|
|
115
|
+
t: ctx.clock.now(),
|
|
116
|
+
ts: ctx.clock.wall(),
|
|
117
|
+
name,
|
|
118
|
+
props,
|
|
119
|
+
};
|
|
120
|
+
ctx.emit(e);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const blocked = (el: Element): boolean => {
|
|
124
|
+
try {
|
|
125
|
+
return !!block && !!el.closest(block);
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const flush = () => {
|
|
132
|
+
if (!focused) return;
|
|
133
|
+
const { el, start, changed } = focused;
|
|
134
|
+
focused = null;
|
|
135
|
+
const ms = Math.min(MAX_FOCUS_MS, Math.max(0, ctx.clock.now() - start));
|
|
136
|
+
// Drop pass-through blips that weren't real interactions.
|
|
137
|
+
if (ms < MIN_FOCUS_MS && !changed) return;
|
|
138
|
+
emit('form_field', {
|
|
139
|
+
form: formKey(el),
|
|
140
|
+
field: fieldKey(el),
|
|
141
|
+
type: controlType(el),
|
|
142
|
+
ms,
|
|
143
|
+
changed,
|
|
144
|
+
filled: isFilled(el),
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onFocusIn = (ev: FocusEvent) => {
|
|
149
|
+
const el = ev.target;
|
|
150
|
+
if (!isTracked(el) || blocked(el)) return;
|
|
151
|
+
if (focused && focused.el !== el) flush(); // safety: blur may have been missed
|
|
152
|
+
focused = { el, start: ctx.clock.now(), changed: false };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const onInput = (ev: Event) => {
|
|
156
|
+
if (focused && ev.target === focused.el) focused.changed = true;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const onFocusOut = (ev: FocusEvent) => {
|
|
160
|
+
if (focused && ev.target === focused.el) flush();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const onSubmit = (ev: Event) => {
|
|
164
|
+
const form = ev.target;
|
|
165
|
+
if (!(form instanceof HTMLFormElement) || blocked(form)) return;
|
|
166
|
+
// Flush the in-progress field so its time lands before the submit marker.
|
|
167
|
+
flush();
|
|
168
|
+
// Reuse a tracked control to derive the key consistently; else fall back to the form itself.
|
|
169
|
+
const probe = form.querySelector<FormControl>('input,textarea,select');
|
|
170
|
+
const key = probe && !blocked(probe) ? formKey(probe) : clip(form.getAttribute('name') || form.id || cssPath(form));
|
|
171
|
+
emit('form_submit', { form: key });
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
document.addEventListener('focusin', onFocusIn, true);
|
|
175
|
+
document.addEventListener('focusout', onFocusOut, true);
|
|
176
|
+
document.addEventListener('input', onInput, true);
|
|
177
|
+
document.addEventListener('submit', onSubmit, true);
|
|
178
|
+
// A navigation/unload can steal focus without a focusout — flush what we have.
|
|
179
|
+
window.addEventListener('pagehide', flush, true);
|
|
180
|
+
|
|
181
|
+
return () => {
|
|
182
|
+
flush();
|
|
183
|
+
document.removeEventListener('focusin', onFocusIn, true);
|
|
184
|
+
document.removeEventListener('focusout', onFocusOut, true);
|
|
185
|
+
document.removeEventListener('input', onInput, true);
|
|
186
|
+
document.removeEventListener('submit', onSubmit, true);
|
|
187
|
+
window.removeEventListener('pagehide', flush, true);
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/global.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IIFE entry for plain <script> embedding. Auto-initialises from data-* attrs:
|
|
3
|
+
*
|
|
4
|
+
* <script src="https://cdn.example.com/obsrviq.global.js"
|
|
5
|
+
* data-obsrviq-key="pk_live_xxx"
|
|
6
|
+
* data-obsrviq-endpoint="https://in.lumera.app"
|
|
7
|
+
* data-obsrviq-mask-inputs="true"
|
|
8
|
+
* data-obsrviq-require-consent="false"></script>
|
|
9
|
+
*
|
|
10
|
+
* Exposes `window.Obsrviq` for manual identify()/track()/setConsent() calls.
|
|
11
|
+
*/
|
|
12
|
+
import { Obsrviq } from './index.js';
|
|
13
|
+
import type { ObsrviqInitConfig } from '@obsrviq/types';
|
|
14
|
+
|
|
15
|
+
declare global {
|
|
16
|
+
interface Window {
|
|
17
|
+
Obsrviq: typeof Obsrviq;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const current = document.currentScript as HTMLScriptElement | null;
|
|
22
|
+
|
|
23
|
+
function bool(v: string | null | undefined, fallback: boolean): boolean {
|
|
24
|
+
if (v == null) return fallback;
|
|
25
|
+
return v === 'true' || v === '1';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (current) {
|
|
29
|
+
const key = current.getAttribute('data-obsrviq-key');
|
|
30
|
+
if (key) {
|
|
31
|
+
const cfg: ObsrviqInitConfig = {
|
|
32
|
+
siteKey: key,
|
|
33
|
+
endpoint: current.getAttribute('data-obsrviq-endpoint') || undefined,
|
|
34
|
+
maskAllInputs: bool(current.getAttribute('data-obsrviq-mask-inputs'), true),
|
|
35
|
+
captureNetworkBodies: bool(current.getAttribute('data-obsrviq-capture-bodies'), false),
|
|
36
|
+
requireConsent: bool(current.getAttribute('data-obsrviq-require-consent'), false),
|
|
37
|
+
askPermission: bool(current.getAttribute('data-obsrviq-ask-permission'), false),
|
|
38
|
+
showRecording: bool(current.getAttribute('data-obsrviq-show-recording'), false),
|
|
39
|
+
recordCanvas: bool(current.getAttribute('data-obsrviq-record-canvas'), false),
|
|
40
|
+
};
|
|
41
|
+
const rate = current.getAttribute('data-obsrviq-sample-rate');
|
|
42
|
+
if (rate) cfg.sampleRate = Number(rate);
|
|
43
|
+
// Identity is usually set later via Obsrviq.identify() (post-login), but can be
|
|
44
|
+
// seeded here when known at load time.
|
|
45
|
+
const userId = current.getAttribute('data-obsrviq-user-id');
|
|
46
|
+
if (userId) cfg.userId = userId;
|
|
47
|
+
const metaAttr = current.getAttribute('data-obsrviq-metadata');
|
|
48
|
+
if (metaAttr) {
|
|
49
|
+
try {
|
|
50
|
+
cfg.metadata = JSON.parse(metaAttr) as Record<string, unknown>;
|
|
51
|
+
} catch {
|
|
52
|
+
console.warn('[obsrviq] data-obsrviq-metadata is not valid JSON — ignored.');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
Obsrviq.init(cfg);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
window.Obsrviq = Obsrviq;
|
|
60
|
+
|
|
61
|
+
export { Obsrviq };
|