@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 CHANGED
@@ -18,6 +18,9 @@ declare class ObsrviqClient {
18
18
  private recording;
19
19
  private consent;
20
20
  private sampledIn;
21
+ /** Manual-control intent. In autoStart:false mode, recording only begins once
22
+ * the host calls startRecording() (which sets this); stopRecording() clears it. */
23
+ private manualStartRequested;
21
24
  private lastActivity;
22
25
  /** The userId this session is attributed to (for the shared-device split). */
23
26
  private currentUserId;
@@ -84,6 +87,16 @@ declare class ObsrviqClient {
84
87
  /** Gate recording (PRIV-2). true starts; false stops + flushes. Dismisses the
85
88
  * built-in consent prompt if one is showing. */
86
89
  setConsent(granted: boolean): void;
90
+ /** Begin recording now. The trigger for `autoStart: false` (manual) mode —
91
+ * nothing records until this is called. Idempotent: a no-op while already
92
+ * recording. Consent, GPC/DNT and sampling still apply (so a consent-gated or
93
+ * sampled-out session won't start just because this was called). After a
94
+ * stopRecording(), this resumes the SAME session with the idle gap shown. */
95
+ startRecording(): void;
96
+ /** Stop recording and flush the final batch. Recording stays off until
97
+ * startRecording() is called again. (Friendlier name for stop(), and it also
98
+ * clears the manual-start intent so recording won't silently resume.) */
99
+ stopRecording(): void;
87
100
  /** Hard stop + flush. */
88
101
  stop(): void;
89
102
  /** Force a flush now (returns when send is attempted). */
package/dist/index.js CHANGED
@@ -1025,6 +1025,26 @@ function instrumentInteractions(ctx) {
1025
1025
  if (!target || !(target instanceof Element)) return;
1026
1026
  const t = ctx.clock.now();
1027
1027
  const selector = cssPath(target);
1028
+ {
1029
+ const rect = target.getBoundingClientRect();
1030
+ const rx = rect.width > 0 ? Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width)) : 0.5;
1031
+ const ry = rect.height > 0 ? Math.min(1, Math.max(0, (ev.clientY - rect.top) / rect.height)) : 0.5;
1032
+ const label = (target.textContent || "").replace(/\s+/g, " ").trim().slice(0, 40);
1033
+ emitCustom(
1034
+ "_click",
1035
+ {
1036
+ selector,
1037
+ path: location.pathname,
1038
+ vw: window.innerWidth,
1039
+ vh: window.innerHeight,
1040
+ rx: Math.round(rx * 1e3) / 1e3,
1041
+ ry: Math.round(ry * 1e3) / 1e3,
1042
+ tag: target.tagName.toLowerCase(),
1043
+ label
1044
+ },
1045
+ t
1046
+ );
1047
+ }
1028
1048
  recent = recent.filter((r) => t - r.t <= RAGE_WINDOW_MS);
1029
1049
  recent.push({ el: target, t });
1030
1050
  const sameTarget = recent.filter((r) => r.el === target);
@@ -1049,6 +1069,7 @@ function instrumentInteractions(ctx) {
1049
1069
  var TRACKED = /* @__PURE__ */ new Set(["INPUT", "TEXTAREA", "SELECT"]);
1050
1070
  var SKIP_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "button", "reset", "image", "file"]);
1051
1071
  var MAX_KEY = 80;
1072
+ var MAX_HINT = 60;
1052
1073
  var MAX_FOCUS_MS = 3e5;
1053
1074
  var MIN_FOCUS_MS = 100;
1054
1075
  function isTracked(el) {
@@ -1092,6 +1113,56 @@ function fieldKey(el) {
1092
1113
  if (ph) return clip(ph);
1093
1114
  return clip(cssPath(el));
1094
1115
  }
1116
+ function clipHint(s) {
1117
+ if (!s) return void 0;
1118
+ const t = s.trim().replace(/\s+/g, " ");
1119
+ if (!t) return void 0;
1120
+ return t.length > MAX_HINT ? t.slice(0, MAX_HINT) : t;
1121
+ }
1122
+ function nearbyText(el) {
1123
+ if (el.labels && el.labels.length) {
1124
+ const t = clipHint(el.labels[0].textContent);
1125
+ if (t) return t;
1126
+ }
1127
+ const aria = clipHint(el.getAttribute("aria-label"));
1128
+ if (aria) return aria;
1129
+ let sib = el.previousElementSibling;
1130
+ for (let hops = 0; sib && hops < 3; hops++, sib = sib.previousElementSibling) {
1131
+ if (TRACKED.has(sib.tagName) || /^(BUTTON|SCRIPT|STYLE|SVG)$/.test(sib.tagName)) continue;
1132
+ const t = clipHint(sib.textContent);
1133
+ if (t) return t;
1134
+ }
1135
+ const parent = el.parentElement;
1136
+ if (parent) {
1137
+ const direct = clipHint(
1138
+ Array.from(parent.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent || "").join(" ")
1139
+ );
1140
+ if (direct) return direct;
1141
+ const lbl = clipHint(parent.querySelector("label,legend")?.textContent);
1142
+ if (lbl) return lbl;
1143
+ }
1144
+ return clipHint(el.getAttribute("placeholder"));
1145
+ }
1146
+ function formTitleHint(el) {
1147
+ const scope = el.closest('form,fieldset,section,[role="form"],[role="group"]');
1148
+ if (scope) {
1149
+ const heading = clipHint(scope.querySelector('legend,h1,h2,h3,h4,[role="heading"]')?.textContent);
1150
+ if (heading) return heading;
1151
+ const al = clipHint(scope.getAttribute("aria-label"));
1152
+ if (al) return al;
1153
+ }
1154
+ return clipHint(typeof document !== "undefined" ? document.title : void 0);
1155
+ }
1156
+ function namingHints(el) {
1157
+ const out = {};
1158
+ const ac = (el.getAttribute("autocomplete") || "").trim().toLowerCase();
1159
+ if (ac && ac !== "off" && ac !== "on") out.autocomplete = ac.slice(0, MAX_HINT);
1160
+ const near = nearbyText(el);
1161
+ if (near) out.near = near;
1162
+ const ft = formTitleHint(el);
1163
+ if (ft) out.formTitle = ft;
1164
+ return out;
1165
+ }
1095
1166
  function formKey(el) {
1096
1167
  const form = el.form;
1097
1168
  if (form) {
@@ -1145,7 +1216,8 @@ function instrumentForms(ctx) {
1145
1216
  type: controlType(el),
1146
1217
  ms,
1147
1218
  changed,
1148
- filled: isFilled(el)
1219
+ filled: isFilled(el),
1220
+ ...namingHints(el)
1149
1221
  });
1150
1222
  };
1151
1223
  const onFocusIn = (ev) => {
@@ -1188,7 +1260,7 @@ function instrumentScrollDepth(ctx) {
1188
1260
  let path = location.pathname;
1189
1261
  let maxBand = 0;
1190
1262
  let scheduled = false;
1191
- const emit = (depth) => {
1263
+ const emit = (depth, h, vh) => {
1192
1264
  const e = {
1193
1265
  id: uuid(),
1194
1266
  sessionId: ctx.sessionId,
@@ -1196,7 +1268,7 @@ function instrumentScrollDepth(ctx) {
1196
1268
  t: ctx.clock.now(),
1197
1269
  ts: ctx.clock.wall(),
1198
1270
  name: "_scroll",
1199
- props: { path, depth }
1271
+ props: { path, depth, h, vh }
1200
1272
  };
1201
1273
  ctx.emit(e);
1202
1274
  };
@@ -1212,11 +1284,12 @@ function instrumentScrollDepth(ctx) {
1212
1284
  maxBand = 0;
1213
1285
  }
1214
1286
  const vh = window.innerHeight || document.documentElement.clientHeight || 1;
1215
- const reached = Math.min(1, (window.scrollY + vh) / docHeight());
1287
+ const h = docHeight();
1288
+ const reached = Math.min(1, (window.scrollY + vh) / h);
1216
1289
  const band = Math.min(100, Math.round(reached * 10) * 10);
1217
1290
  if (band > maxBand) {
1218
1291
  maxBand = band;
1219
- emit(band);
1292
+ emit(band, h, vh);
1220
1293
  }
1221
1294
  };
1222
1295
  const onScroll = () => {
@@ -1498,6 +1571,7 @@ var DEFAULTS = {
1498
1571
  requireConsent: false,
1499
1572
  askPermission: false,
1500
1573
  showRecording: false,
1574
+ autoStart: true,
1501
1575
  maxArgBytes: 8 * 1024
1502
1576
  };
1503
1577
  var ObsrviqClient = class {
@@ -1510,6 +1584,9 @@ var ObsrviqClient = class {
1510
1584
  this.recording = false;
1511
1585
  this.consent = null;
1512
1586
  this.sampledIn = true;
1587
+ /** Manual-control intent. In autoStart:false mode, recording only begins once
1588
+ * the host calls startRecording() (which sets this); stopRecording() clears it. */
1589
+ this.manualStartRequested = false;
1513
1590
  this.lastActivity = 0;
1514
1591
  /** Next batch seq — mirrored from the transport so we can persist continuation. */
1515
1592
  this.nextSeq = 0;
@@ -1630,6 +1707,7 @@ var ObsrviqClient = class {
1630
1707
  }
1631
1708
  maybeStart() {
1632
1709
  if (this.recording || !this.config || !this.clock || !this.transport) return;
1710
+ if (this.config.autoStart === false && !this.manualStartRequested) return;
1633
1711
  if (this.config.requireConsent && this.consent !== true) return;
1634
1712
  if (this.consent === false) return;
1635
1713
  if (!this.sampledIn) return;
@@ -1787,6 +1865,22 @@ var ObsrviqClient = class {
1787
1865
  if (granted) this.maybeStart();
1788
1866
  else if (this.recording) this.teardown();
1789
1867
  }
1868
+ /** Begin recording now. The trigger for `autoStart: false` (manual) mode —
1869
+ * nothing records until this is called. Idempotent: a no-op while already
1870
+ * recording. Consent, GPC/DNT and sampling still apply (so a consent-gated or
1871
+ * sampled-out session won't start just because this was called). After a
1872
+ * stopRecording(), this resumes the SAME session with the idle gap shown. */
1873
+ startRecording() {
1874
+ this.manualStartRequested = true;
1875
+ this.maybeStart();
1876
+ }
1877
+ /** Stop recording and flush the final batch. Recording stays off until
1878
+ * startRecording() is called again. (Friendlier name for stop(), and it also
1879
+ * clears the manual-start intent so recording won't silently resume.) */
1880
+ stopRecording() {
1881
+ this.manualStartRequested = false;
1882
+ if (this.recording) this.teardown();
1883
+ }
1790
1884
  /** Hard stop + flush. */
1791
1885
  stop() {
1792
1886
  this.teardown();