@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsrviq/tracker",
3
- "version": "0.3.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.3.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"></script>
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();
@@ -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 reached = Math.min(1, (window.scrollY + vh) / docHeight());
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