@obsrviq/tracker 0.3.0 → 0.4.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/index.d.ts +13 -0
- package/dist/index.js +99 -5
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +24 -24
- package/dist/obsrviq.global.js.map +1 -1
- package/package.json +2 -2
- package/src/context.ts +2 -0
- package/src/forms.ts +75 -0
- package/src/global.ts +5 -1
- package/src/index.ts +24 -0
- package/src/interactions.ts +24 -0
- package/src/scroll.ts +10 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obsrviq/tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@rrweb/types": "2.0.1",
|
|
28
28
|
"fflate": "^0.8.2",
|
|
29
29
|
"rrweb": "2.0.1",
|
|
30
|
-
"@obsrviq/types": "0.
|
|
30
|
+
"@obsrviq/types": "0.4.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"tsup": "^8.3.5",
|
package/src/context.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface ResolvedConfig {
|
|
|
24
24
|
requireConsent: boolean;
|
|
25
25
|
askPermission: boolean;
|
|
26
26
|
showRecording: boolean;
|
|
27
|
+
/** Auto-start recording on init(). False = manual (startRecording()/stopRecording()). */
|
|
28
|
+
autoStart: boolean;
|
|
27
29
|
consentText?: { title?: string; body?: string; allow?: string; deny?: string };
|
|
28
30
|
beforeSend?: (batch: IngestBatch) => IngestBatch | null;
|
|
29
31
|
maxArgBytes: number;
|
package/src/forms.ts
CHANGED
|
@@ -16,6 +16,11 @@ import { uuid } from './util.js';
|
|
|
16
16
|
* `form_submit` event is emitted when a form is submitted; the last field
|
|
17
17
|
* touched in a session whose form never submitted is the drop-off point.
|
|
18
18
|
*
|
|
19
|
+
* Each event also carries optional naming hints — `autocomplete`, `near` (the
|
|
20
|
+
* field's visible label text), and `formTitle` (the form's heading) — so the
|
|
21
|
+
* server can turn opaque keys (a React `useId()`, a CSS-selector path) into
|
|
22
|
+
* human names like "Work email". Hints are static UI chrome, never values.
|
|
23
|
+
*
|
|
19
24
|
* Privacy: values are never read. `filled` is a boolean derived from value
|
|
20
25
|
* length (or `checked`); the field key comes from structural attributes
|
|
21
26
|
* (name / id / label), exactly as form-analytics tools do. Controls inside a
|
|
@@ -27,6 +32,7 @@ const TRACKED = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
|
27
32
|
// measuring (and file is privacy-adjacent), so they're left out.
|
|
28
33
|
const SKIP_INPUT_TYPES = new Set(['hidden', 'submit', 'button', 'reset', 'image', 'file']);
|
|
29
34
|
const MAX_KEY = 80;
|
|
35
|
+
const MAX_HINT = 60; // naming hints are short labels, not keys
|
|
30
36
|
const MAX_FOCUS_MS = 300_000; // cap idle-inflated focus time (walked-away tab)
|
|
31
37
|
const MIN_FOCUS_MS = 100; // ignore sub-100ms programmatic focus blips
|
|
32
38
|
|
|
@@ -80,6 +86,74 @@ function fieldKey(el: FormControl): string {
|
|
|
80
86
|
return clip(cssPath(el));
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
function clipHint(s: string | null | undefined): string | undefined {
|
|
90
|
+
if (!s) return undefined;
|
|
91
|
+
const t = s.trim().replace(/\s+/g, ' ');
|
|
92
|
+
if (!t) return undefined;
|
|
93
|
+
return t.length > MAX_HINT ? t.slice(0, MAX_HINT) : t;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Visible text a human reads as this field's label — used ONLY as a naming hint
|
|
98
|
+
* for the server's AI pass, never as the stable key. It reads static UI chrome
|
|
99
|
+
* (a <label>, a wrapper's own text, a preceding label element) so opaque keys
|
|
100
|
+
* like a React `useId()` or a CSS-selector path can still be named "Work email".
|
|
101
|
+
* The control's value is never read; tracked controls and buttons are skipped so
|
|
102
|
+
* a sibling input's contents can't leak in.
|
|
103
|
+
*/
|
|
104
|
+
function nearbyText(el: FormControl): string | undefined {
|
|
105
|
+
if (el.labels && el.labels.length) {
|
|
106
|
+
const t = clipHint(el.labels[0]!.textContent);
|
|
107
|
+
if (t) return t;
|
|
108
|
+
}
|
|
109
|
+
const aria = clipHint(el.getAttribute('aria-label'));
|
|
110
|
+
if (aria) return aria;
|
|
111
|
+
// A label rendered as a plain element just before the control.
|
|
112
|
+
let sib: Element | null = el.previousElementSibling;
|
|
113
|
+
for (let hops = 0; sib && hops < 3; hops++, sib = sib.previousElementSibling) {
|
|
114
|
+
if (TRACKED.has(sib.tagName) || /^(BUTTON|SCRIPT|STYLE|SVG)$/.test(sib.tagName)) continue;
|
|
115
|
+
const t = clipHint(sib.textContent);
|
|
116
|
+
if (t) return t;
|
|
117
|
+
}
|
|
118
|
+
// <div class="field">Label <input/></div> — the wrapper's own direct text.
|
|
119
|
+
const parent = el.parentElement;
|
|
120
|
+
if (parent) {
|
|
121
|
+
const direct = clipHint(
|
|
122
|
+
Array.from(parent.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent || '').join(' '),
|
|
123
|
+
);
|
|
124
|
+
if (direct) return direct;
|
|
125
|
+
const lbl = clipHint(parent.querySelector('label,legend')?.textContent);
|
|
126
|
+
if (lbl) return lbl;
|
|
127
|
+
}
|
|
128
|
+
return clipHint(el.getAttribute('placeholder'));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The nearest heading/legend a user sees over the owning form — a naming hint. */
|
|
132
|
+
function formTitleHint(el: FormControl): string | undefined {
|
|
133
|
+
const scope = el.closest('form,fieldset,section,[role="form"],[role="group"]');
|
|
134
|
+
if (scope) {
|
|
135
|
+
const heading = clipHint(scope.querySelector('legend,h1,h2,h3,h4,[role="heading"]')?.textContent);
|
|
136
|
+
if (heading) return heading;
|
|
137
|
+
const al = clipHint(scope.getAttribute('aria-label'));
|
|
138
|
+
if (al) return al;
|
|
139
|
+
}
|
|
140
|
+
return clipHint(typeof document !== 'undefined' ? document.title : undefined);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Privacy-safe descriptive hints (label text, autocomplete intent, form heading)
|
|
144
|
+
* attached to each interaction so the server can name opaque fields/forms. Only
|
|
145
|
+
* present keys are emitted, keeping the wire small. */
|
|
146
|
+
function namingHints(el: FormControl): Record<string, string> {
|
|
147
|
+
const out: Record<string, string> = {};
|
|
148
|
+
const ac = (el.getAttribute('autocomplete') || '').trim().toLowerCase();
|
|
149
|
+
if (ac && ac !== 'off' && ac !== 'on') out.autocomplete = ac.slice(0, MAX_HINT);
|
|
150
|
+
const near = nearbyText(el);
|
|
151
|
+
if (near) out.near = near;
|
|
152
|
+
const ft = formTitleHint(el);
|
|
153
|
+
if (ft) out.formTitle = ft;
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
83
157
|
/** A stable identity for the owning form (or a per-page bucket for loose fields). */
|
|
84
158
|
function formKey(el: FormControl): string {
|
|
85
159
|
const form = el.form;
|
|
@@ -142,6 +216,7 @@ export function instrumentForms(ctx: InstrumentCtx): Teardown {
|
|
|
142
216
|
ms,
|
|
143
217
|
changed,
|
|
144
218
|
filled: isFilled(el),
|
|
219
|
+
...namingHints(el),
|
|
145
220
|
});
|
|
146
221
|
};
|
|
147
222
|
|
package/src/global.ts
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* data-obsrviq-key="pk_live_xxx"
|
|
6
6
|
* data-obsrviq-endpoint="https://in.lumera.app"
|
|
7
7
|
* data-obsrviq-mask-inputs="true"
|
|
8
|
-
* data-obsrviq-require-consent="false"
|
|
8
|
+
* data-obsrviq-require-consent="false"
|
|
9
|
+
* data-obsrviq-auto-start="true"></script>
|
|
9
10
|
*
|
|
10
11
|
* Exposes `window.Obsrviq` for manual identify()/track()/setConsent() calls.
|
|
12
|
+
* For manual recording control, set data-obsrviq-auto-start="false" and call
|
|
13
|
+
* Obsrviq.startRecording() / Obsrviq.stopRecording() yourself.
|
|
11
14
|
*/
|
|
12
15
|
import { Obsrviq } from './index.js';
|
|
13
16
|
import type { ObsrviqInitConfig } from '@obsrviq/types';
|
|
@@ -37,6 +40,7 @@ if (current) {
|
|
|
37
40
|
askPermission: bool(current.getAttribute('data-obsrviq-ask-permission'), false),
|
|
38
41
|
showRecording: bool(current.getAttribute('data-obsrviq-show-recording'), false),
|
|
39
42
|
recordCanvas: bool(current.getAttribute('data-obsrviq-record-canvas'), false),
|
|
43
|
+
autoStart: bool(current.getAttribute('data-obsrviq-auto-start'), true),
|
|
40
44
|
};
|
|
41
45
|
const rate = current.getAttribute('data-obsrviq-sample-rate');
|
|
42
46
|
if (rate) cfg.sampleRate = Number(rate);
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ const DEFAULTS = {
|
|
|
29
29
|
requireConsent: false,
|
|
30
30
|
askPermission: false,
|
|
31
31
|
showRecording: false,
|
|
32
|
+
autoStart: true,
|
|
32
33
|
maxArgBytes: 8 * 1024,
|
|
33
34
|
};
|
|
34
35
|
|
|
@@ -50,6 +51,9 @@ class ObsrviqClient {
|
|
|
50
51
|
private recording = false;
|
|
51
52
|
private consent: boolean | null = null;
|
|
52
53
|
private sampledIn = true;
|
|
54
|
+
/** Manual-control intent. In autoStart:false mode, recording only begins once
|
|
55
|
+
* the host calls startRecording() (which sets this); stopRecording() clears it. */
|
|
56
|
+
private manualStartRequested = false;
|
|
53
57
|
private lastActivity = 0;
|
|
54
58
|
/** The userId this session is attributed to (for the shared-device split). */
|
|
55
59
|
private currentUserId: string | undefined;
|
|
@@ -198,6 +202,8 @@ class ObsrviqClient {
|
|
|
198
202
|
|
|
199
203
|
private maybeStart(): void {
|
|
200
204
|
if (this.recording || !this.config || !this.clock || !this.transport) return;
|
|
205
|
+
// Manual mode: hold off until the host explicitly calls startRecording().
|
|
206
|
+
if (this.config.autoStart === false && !this.manualStartRequested) return;
|
|
201
207
|
if (this.config.requireConsent && this.consent !== true) return;
|
|
202
208
|
if (this.consent === false) return;
|
|
203
209
|
if (!this.sampledIn) return;
|
|
@@ -393,6 +399,24 @@ class ObsrviqClient {
|
|
|
393
399
|
else if (this.recording) this.teardown();
|
|
394
400
|
}
|
|
395
401
|
|
|
402
|
+
/** Begin recording now. The trigger for `autoStart: false` (manual) mode —
|
|
403
|
+
* nothing records until this is called. Idempotent: a no-op while already
|
|
404
|
+
* recording. Consent, GPC/DNT and sampling still apply (so a consent-gated or
|
|
405
|
+
* sampled-out session won't start just because this was called). After a
|
|
406
|
+
* stopRecording(), this resumes the SAME session with the idle gap shown. */
|
|
407
|
+
startRecording(): void {
|
|
408
|
+
this.manualStartRequested = true;
|
|
409
|
+
this.maybeStart();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Stop recording and flush the final batch. Recording stays off until
|
|
413
|
+
* startRecording() is called again. (Friendlier name for stop(), and it also
|
|
414
|
+
* clears the manual-start intent so recording won't silently resume.) */
|
|
415
|
+
stopRecording(): void {
|
|
416
|
+
this.manualStartRequested = false;
|
|
417
|
+
if (this.recording) this.teardown();
|
|
418
|
+
}
|
|
419
|
+
|
|
396
420
|
/** Hard stop + flush. */
|
|
397
421
|
stop(): void {
|
|
398
422
|
this.teardown();
|
package/src/interactions.ts
CHANGED
|
@@ -44,6 +44,30 @@ export function instrumentInteractions(ctx: InstrumentCtx): Teardown {
|
|
|
44
44
|
const t = ctx.clock.now();
|
|
45
45
|
const selector = cssPath(target);
|
|
46
46
|
|
|
47
|
+
// ── Click heatmap: one event per click, anchored to the exact element ──
|
|
48
|
+
// Stored against (path, selector, device) and rolled up server-side. rx/ry are
|
|
49
|
+
// the click position WITHIN the element (0..1) so heat re-projects at any size.
|
|
50
|
+
{
|
|
51
|
+
const rect = target.getBoundingClientRect();
|
|
52
|
+
const rx = rect.width > 0 ? Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width)) : 0.5;
|
|
53
|
+
const ry = rect.height > 0 ? Math.min(1, Math.max(0, (ev.clientY - rect.top) / rect.height)) : 0.5;
|
|
54
|
+
const label = (target.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
55
|
+
emitCustom(
|
|
56
|
+
'_click',
|
|
57
|
+
{
|
|
58
|
+
selector,
|
|
59
|
+
path: location.pathname,
|
|
60
|
+
vw: window.innerWidth,
|
|
61
|
+
vh: window.innerHeight,
|
|
62
|
+
rx: Math.round(rx * 1000) / 1000,
|
|
63
|
+
ry: Math.round(ry * 1000) / 1000,
|
|
64
|
+
tag: target.tagName.toLowerCase(),
|
|
65
|
+
label,
|
|
66
|
+
},
|
|
67
|
+
t,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
47
71
|
// ── Rage click detection ──
|
|
48
72
|
recent = recent.filter((r) => t - r.t <= RAGE_WINDOW_MS);
|
|
49
73
|
recent.push({ el: target, t });
|
package/src/scroll.ts
CHANGED
|
@@ -8,13 +8,18 @@ import { uuid } from './util.js';
|
|
|
8
8
|
* each session's max depth per page. Cheap (a handful of events per page at
|
|
9
9
|
* most) and value-free. The page identity is the pathname; depth resets on SPA
|
|
10
10
|
* route changes.
|
|
11
|
+
*
|
|
12
|
+
* Each event also carries the page height `h` (full document height, px) and
|
|
13
|
+
* viewport `vh` at that moment — so the server can tell apart "100% of a short
|
|
14
|
+
* stub" from "100% of a long article", report absolute pixels reached, and flag
|
|
15
|
+
* when a templated group blends pages of very different lengths.
|
|
11
16
|
*/
|
|
12
17
|
export function instrumentScrollDepth(ctx: InstrumentCtx): Teardown {
|
|
13
18
|
let path = location.pathname;
|
|
14
19
|
let maxBand = 0; // deepest 10% band reached on the current path
|
|
15
20
|
let scheduled = false;
|
|
16
21
|
|
|
17
|
-
const emit = (depth: number) => {
|
|
22
|
+
const emit = (depth: number, h: number, vh: number) => {
|
|
18
23
|
const e: ObsrviqCustomEvent = {
|
|
19
24
|
id: uuid(),
|
|
20
25
|
sessionId: ctx.sessionId,
|
|
@@ -22,7 +27,7 @@ export function instrumentScrollDepth(ctx: InstrumentCtx): Teardown {
|
|
|
22
27
|
t: ctx.clock.now(),
|
|
23
28
|
ts: ctx.clock.wall(),
|
|
24
29
|
name: '_scroll',
|
|
25
|
-
props: { path, depth },
|
|
30
|
+
props: { path, depth, h, vh },
|
|
26
31
|
};
|
|
27
32
|
ctx.emit(e);
|
|
28
33
|
};
|
|
@@ -41,11 +46,12 @@ export function instrumentScrollDepth(ctx: InstrumentCtx): Teardown {
|
|
|
41
46
|
maxBand = 0;
|
|
42
47
|
}
|
|
43
48
|
const vh = window.innerHeight || document.documentElement.clientHeight || 1;
|
|
44
|
-
const
|
|
49
|
+
const h = docHeight();
|
|
50
|
+
const reached = Math.min(1, (window.scrollY + vh) / h);
|
|
45
51
|
const band = Math.min(100, Math.round(reached * 10) * 10);
|
|
46
52
|
if (band > maxBand) {
|
|
47
53
|
maxBand = band;
|
|
48
|
-
emit(band);
|
|
54
|
+
emit(band, h, vh);
|
|
49
55
|
}
|
|
50
56
|
};
|
|
51
57
|
|