@sailfish-ai/recorder 1.12.7 → 1.12.9

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.
Files changed (62) hide show
  1. package/dist/chunks/{canvasManager-Ce7vazTt.js → canvasManager-CLCtW5BZ.js} +1 -1
  2. package/dist/chunks/canvasManager-CLCtW5BZ.js.br +0 -0
  3. package/dist/chunks/canvasManager-CLCtW5BZ.js.gz +0 -0
  4. package/dist/chunks/{canvasManager-B9uQ-PFS.js → canvasManager-Dtum5H28.js} +1 -1
  5. package/dist/chunks/canvasManager-Dtum5H28.js.br +0 -0
  6. package/dist/chunks/canvasManager-Dtum5H28.js.gz +0 -0
  7. package/dist/chunks/{chunkSerializer-C1knjCQ5.js → chunkSerializer-9QANwItv.js} +1 -1
  8. package/dist/chunks/chunkSerializer-9QANwItv.js.br +0 -0
  9. package/dist/chunks/chunkSerializer-9QANwItv.js.gz +0 -0
  10. package/dist/chunks/{chunkSerializer-B-z7p0r-.js → chunkSerializer-Ds0Flu_e.js} +1 -1
  11. package/dist/chunks/chunkSerializer-Ds0Flu_e.js.br +0 -0
  12. package/dist/chunks/chunkSerializer-Ds0Flu_e.js.gz +0 -0
  13. package/dist/chunks/hoverDetection-Bl-yKKI3.js +137 -0
  14. package/dist/chunks/hoverDetection-Bl-yKKI3.js.br +0 -0
  15. package/dist/chunks/hoverDetection-Bl-yKKI3.js.gz +0 -0
  16. package/dist/chunks/hoverDetection-CYsyBQjj.js +139 -0
  17. package/dist/chunks/hoverDetection-CYsyBQjj.js.br +0 -0
  18. package/dist/chunks/hoverDetection-CYsyBQjj.js.gz +0 -0
  19. package/dist/chunks/{index-BMHQ-0NY.js → index-BRybWPUS.js} +142 -128
  20. package/dist/chunks/index-BRybWPUS.js.br +0 -0
  21. package/dist/chunks/index-BRybWPUS.js.gz +0 -0
  22. package/dist/chunks/{index-qgM1hFXO.js → index-Cp1vLB5Q.js} +120 -107
  23. package/dist/chunks/index-Cp1vLB5Q.js.br +0 -0
  24. package/dist/chunks/index-Cp1vLB5Q.js.gz +0 -0
  25. package/dist/hoverDetection.js +371 -0
  26. package/dist/hoverDetection.js.br +0 -0
  27. package/dist/hoverDetection.js.gz +0 -0
  28. package/dist/hoverDetection.test.js +187 -0
  29. package/dist/hoverDetection.test.js.br +0 -0
  30. package/dist/hoverDetection.test.js.gz +0 -0
  31. package/dist/index.js +23 -1
  32. package/dist/index.js.br +0 -0
  33. package/dist/index.js.gz +0 -0
  34. package/dist/recorder.cjs +2 -2
  35. package/dist/recorder.cjs.br +0 -0
  36. package/dist/recorder.cjs.gz +0 -0
  37. package/dist/recorder.js +13 -12
  38. package/dist/recorder.js.br +7 -2
  39. package/dist/recorder.js.gz +0 -0
  40. package/dist/recorder.umd.cjs +597 -452
  41. package/dist/recorder.umd.cjs.br +0 -0
  42. package/dist/recorder.umd.cjs.gz +0 -0
  43. package/dist/recording.js +27 -0
  44. package/dist/recording.js.br +0 -0
  45. package/dist/recording.js.gz +0 -0
  46. package/dist/types/hoverDetection.d.ts +37 -0
  47. package/dist/types/hoverDetection.test.d.ts +1 -0
  48. package/dist/types/index.d.ts +1 -0
  49. package/dist/types/types.d.ts +6 -0
  50. package/package.json +1 -1
  51. package/dist/chunks/canvasManager-B9uQ-PFS.js.br +0 -0
  52. package/dist/chunks/canvasManager-B9uQ-PFS.js.gz +0 -0
  53. package/dist/chunks/canvasManager-Ce7vazTt.js.br +0 -0
  54. package/dist/chunks/canvasManager-Ce7vazTt.js.gz +0 -0
  55. package/dist/chunks/chunkSerializer-B-z7p0r-.js.br +0 -0
  56. package/dist/chunks/chunkSerializer-B-z7p0r-.js.gz +0 -0
  57. package/dist/chunks/chunkSerializer-C1knjCQ5.js.br +0 -0
  58. package/dist/chunks/chunkSerializer-C1knjCQ5.js.gz +0 -0
  59. package/dist/chunks/index-BMHQ-0NY.js.br +0 -0
  60. package/dist/chunks/index-BMHQ-0NY.js.gz +0 -0
  61. package/dist/chunks/index-qgM1hFXO.js.br +0 -0
  62. package/dist/chunks/index-qgM1hFXO.js.gz +0 -0
@@ -0,0 +1,371 @@
1
+ // Hover action detection (DIAGPLAT-1790) — v2.
2
+ //
3
+ // rrweb has no native hover/mouseover interaction, so the recorder detects it
4
+ // itself. v2 design:
5
+ // • Detect dwell over an element with hover-like affordance (tooltip, menu/
6
+ // popover trigger, cursor:pointer, or a matching `:hover` CSS rule).
7
+ // • At the dwell mark, RE-CHECK `el.matches(":hover")` — only then is it a
8
+ // real, intentional hover (kills the entered-then-left race).
9
+ // • Emit ONCE at hover-END, carrying `duration_ms` (real dwell time).
10
+ // • Suppress at the source: if the hover ended because the user CLICKED the
11
+ // element, don't emit (a hover→click is just a click). Because this is
12
+ // decided locally and in order, it needs no backend/Kafka-batch dedup.
13
+ // • Volume guard before anything is emitted: per-element cooldown +
14
+ // token-bucket rate limit + hard session cap — so a pathological page can
15
+ // never flood the WebSocket/Kafka/Postgres.
16
+ //
17
+ // SSR-safe: every entry point guards against a missing window/document.
18
+ import { EventType } from "@sailfish-rrweb/types";
19
+ // ARIA roles that imply hover-revealed UI (menus, popovers, tooltips).
20
+ const MENU_ROLES = new Set(["menu", "menuitem", "menubar", "tooltip"]);
21
+ // Memoized "base" selectors (rightmost compound, `:hover` stripped) from every
22
+ // readable stylesheet. Recomputed only when the stylesheet count changes.
23
+ let cachedSheetCount = -1;
24
+ let hoverBaseSelectors = [];
25
+ // From a single comma-separated selector that contains `:hover`, push the
26
+ // "base" selector of the element that is actually HOVERED. The `:hover` may
27
+ // qualify any compound in the chain, not the rightmost one: in
28
+ // `.menu:hover .item` the user hovers `.menu` (and `.item` is merely revealed),
29
+ // so the affordance element is `.menu`, NOT `.item`. We therefore split the
30
+ // selector into its compounds (by combinator) and, for each compound that
31
+ // carries `:hover`, strip the `:hover` (and any pseudo-element) to recover the
32
+ // structural selector to match against.
33
+ function collectHoverBases(selectorText, out) {
34
+ for (const part of selectorText.split(",")) {
35
+ if (part.indexOf(":hover") === -1)
36
+ continue;
37
+ // Split into compounds on combinators (descendant / > / + / ~).
38
+ for (const compound of part.split(/\s*[>+~]\s*|\s+/)) {
39
+ if (compound.indexOf(":hover") === -1)
40
+ continue;
41
+ const base = compound
42
+ .replace(/:hover\b/g, "")
43
+ // pseudo-elements (::before/::after) el.matches() can't evaluate —
44
+ // drop them so e.g. `.tip:hover::after` still resolves to `.tip`.
45
+ .replace(/::[a-zA-Z-]+/g, "")
46
+ .trim();
47
+ if (base)
48
+ out.push(base);
49
+ }
50
+ }
51
+ }
52
+ // Walk a rule list, descending into grouping rules (@media / @supports /
53
+ // @layer / @container) and CSS-nested rules — a `:hover` rule nested inside
54
+ // `@media (hover: hover) { … }` (the modern best-practice pattern) would
55
+ // otherwise be invisible, so strict affordance would miss it entirely.
56
+ function collectFromRules(rules, out, depth) {
57
+ if (depth > 10)
58
+ return; // pathological nesting guard
59
+ for (let j = 0; j < rules.length; j++) {
60
+ const rule = rules[j];
61
+ const selectorText = rule.selectorText;
62
+ if (selectorText && selectorText.indexOf(":hover") !== -1) {
63
+ collectHoverBases(selectorText, out);
64
+ }
65
+ // CSSGroupingRule (and CSS-nested CSSStyleRule) expose `.cssRules`.
66
+ let nested = null;
67
+ try {
68
+ nested = rule.cssRules ?? null;
69
+ }
70
+ catch {
71
+ nested = null; // inaccessible
72
+ }
73
+ if (nested && nested.length)
74
+ collectFromRules(nested, out, depth + 1);
75
+ }
76
+ }
77
+ function getHoverBaseSelectors() {
78
+ const sheets = document.styleSheets;
79
+ if (sheets.length === cachedSheetCount)
80
+ return hoverBaseSelectors;
81
+ cachedSheetCount = sheets.length;
82
+ const selectors = [];
83
+ for (let i = 0; i < sheets.length; i++) {
84
+ let rules = null;
85
+ try {
86
+ rules = sheets[i].cssRules;
87
+ }
88
+ catch {
89
+ continue; // cross-origin / inaccessible stylesheet
90
+ }
91
+ if (!rules)
92
+ continue;
93
+ collectFromRules(rules, selectors, 0);
94
+ }
95
+ hoverBaseSelectors = selectors;
96
+ return selectors;
97
+ }
98
+ function matchesHoverRule(el) {
99
+ const selectors = getHoverBaseSelectors();
100
+ for (let i = 0; i < selectors.length; i++) {
101
+ try {
102
+ if (el.matches(selectors[i]))
103
+ return true;
104
+ }
105
+ catch {
106
+ // .matches() rejects malformed/unsupported selectors — ignore them.
107
+ }
108
+ }
109
+ return false;
110
+ }
111
+ /**
112
+ * True when `el` itself exposes hover-like behavior. Cheapest checks first.
113
+ * In `strict` mode bare `cursor:pointer` does NOT qualify — only a genuine
114
+ * hover effect (tooltip, menu/popover, or a matching `:hover` CSS rule).
115
+ */
116
+ function hasHoverAffordance(el, strict) {
117
+ // Hover-revealed UI (tooltip / menu / popover) — real hover behavior even
118
+ // when implemented in JS rather than a `:hover` rule.
119
+ if (el.hasAttribute("title") ||
120
+ el.hasAttribute("data-tooltip") ||
121
+ el.hasAttribute("aria-describedby")) {
122
+ return true;
123
+ }
124
+ const role = el.getAttribute("role");
125
+ if (el.hasAttribute("aria-haspopup") || (role && MENU_ROLES.has(role))) {
126
+ return true;
127
+ }
128
+ // A genuine CSS hover effect targets this element — the gold standard.
129
+ if (matchesHoverRule(el))
130
+ return true;
131
+ // cursor:pointer is broad (most clickable elements set it, with no actual
132
+ // hover effect) — only counts in non-strict mode.
133
+ if (!strict) {
134
+ try {
135
+ if (typeof window.getComputedStyle === "function") {
136
+ if (window.getComputedStyle(el).cursor === "pointer")
137
+ return true;
138
+ }
139
+ }
140
+ catch {
141
+ // getComputedStyle can throw on detached nodes — ignore.
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+ /** Walk up from `start` to the nearest element with hover affordance. */
147
+ function resolveHoverAffordance(start, maxDepth, strict) {
148
+ let el = start;
149
+ let depth = 0;
150
+ while (el && depth <= maxDepth) {
151
+ if (hasHoverAffordance(el, strict))
152
+ return el;
153
+ el = el.parentElement;
154
+ depth++;
155
+ }
156
+ return null;
157
+ }
158
+ /**
159
+ * Confirm the cursor is actually still over `el` at emit time. `:hover` matches
160
+ * an element when the pointer is over it OR a descendant — exactly what we want
161
+ * (we track the affordance ancestor). If the environment can't evaluate `:hover`
162
+ * (older jsdom, etc.), don't drop the hover — we only got here via a real
163
+ * pointerover + dwell with no intervening pointerout.
164
+ */
165
+ function stillHovering(el) {
166
+ try {
167
+ return el.matches(":hover");
168
+ }
169
+ catch {
170
+ return true;
171
+ }
172
+ }
173
+ /** Simple token bucket: `capacity` burst, refills `refillPerSec` tokens/sec. */
174
+ class TokenBucket {
175
+ capacity;
176
+ refillPerSec;
177
+ tokens;
178
+ last;
179
+ constructor(capacity, refillPerSec, now) {
180
+ this.capacity = capacity;
181
+ this.refillPerSec = refillPerSec;
182
+ this.tokens = capacity;
183
+ this.last = now;
184
+ }
185
+ tryConsume(now) {
186
+ this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.refillPerSec);
187
+ this.last = now;
188
+ if (this.tokens < 1)
189
+ return false;
190
+ this.tokens -= 1;
191
+ return true;
192
+ }
193
+ }
194
+ const COOLDOWN_LRU_CAP = 500;
195
+ // Single detector per document. If the recorder re-initializes (reconnect, SPA
196
+ // re-init, bfcache pageshow re-arm), a second install would otherwise STACK a
197
+ // second set of capture-phase listeners on top of the first — double-emitting
198
+ // every hover and leaking the originals. Tear down the previous install first.
199
+ let activeTeardown = null;
200
+ /**
201
+ * Install document-level hover detection. Returns a teardown function. No-op
202
+ * (returns a no-op teardown) outside the browser. Idempotent: a second call
203
+ * tears down the previous install before arming a new one.
204
+ */
205
+ export function installHoverDetection(record, options) {
206
+ if (typeof window === "undefined" || typeof document === "undefined") {
207
+ return () => { };
208
+ }
209
+ // Reclaim any detector from a prior install before arming a new one.
210
+ if (activeTeardown) {
211
+ activeTeardown();
212
+ activeTeardown = null;
213
+ }
214
+ const dwellMs = Math.max(0, options.dwellMs);
215
+ const cooldownMs = Math.max(0, options.cooldownMs ?? 3000);
216
+ const maxPerSession = options.maxPerSession ?? 300;
217
+ const maxPerSecond = options.maxPerSecond ?? 1;
218
+ const maxDwellMs = options.maxDwellMs ?? 60_000;
219
+ const maxDepth = options.maxAncestorDepth ?? 5;
220
+ const strict = options.strictAffordance ?? true;
221
+ // Date.now() (ms) is plenty for dwell timing and is advanced by fake timers
222
+ // in tests; performance.now() isn't reliably faked.
223
+ const now = () => Date.now();
224
+ // ── volume guard state ──────────────────────────────────────────────────
225
+ const lastEmitById = new Map();
226
+ let sessionHoverCount = 0;
227
+ const bucket = new TokenBucket(Math.max(10, maxPerSecond), maxPerSecond, now());
228
+ const passesVolumeGate = (id, t) => {
229
+ if (sessionHoverCount >= maxPerSession)
230
+ return false;
231
+ const last = lastEmitById.get(id);
232
+ if (last !== undefined && t - last < cooldownMs)
233
+ return false;
234
+ if (!bucket.tryConsume(t))
235
+ return false;
236
+ // record + LRU-cap the cooldown map
237
+ lastEmitById.delete(id);
238
+ lastEmitById.set(id, t);
239
+ if (lastEmitById.size > COOLDOWN_LRU_CAP) {
240
+ const oldest = lastEmitById.keys().next().value;
241
+ if (oldest !== undefined)
242
+ lastEmitById.delete(oldest);
243
+ }
244
+ sessionHoverCount += 1;
245
+ return true;
246
+ };
247
+ // ── per-hover tracking state ────────────────────────────────────────────
248
+ let trackedEl = null;
249
+ let enterTime = 0;
250
+ let dwellReached = false;
251
+ let dwellTimer = null;
252
+ let capTimer = null;
253
+ const clearTimers = () => {
254
+ if (dwellTimer !== null) {
255
+ clearTimeout(dwellTimer);
256
+ dwellTimer = null;
257
+ }
258
+ if (capTimer !== null) {
259
+ clearTimeout(capTimer);
260
+ capTimer = null;
261
+ }
262
+ };
263
+ const reset = () => {
264
+ clearTimers();
265
+ trackedEl = null;
266
+ dwellReached = false;
267
+ };
268
+ const emit = (el, startedAt, endedAt) => {
269
+ const id = record.mirror?.getId?.(el) ?? -1;
270
+ if (!id || id <= 0)
271
+ return; // not in the rrweb mirror → backend can't resolve it
272
+ if (!passesVolumeGate(id, endedAt))
273
+ return;
274
+ record.addSailfishEvent?.(EventType.SailfishCustom, {
275
+ action: "hover",
276
+ element_id: id,
277
+ started_at: Math.round(startedAt),
278
+ ended_at: Math.round(endedAt),
279
+ // Clamp ≥ 0: Date.now() isn't monotonic, so a clock step could otherwise
280
+ // produce a negative span.
281
+ duration_ms: Math.max(0, Math.round(endedAt - startedAt)),
282
+ });
283
+ };
284
+ // End the current hover. `emitted-by` semantics: emit only for genuine ends
285
+ // (leave / switch / cap / flush) once dwell was reached. A click END
286
+ // suppresses (hover→click is just a click); a raced-out dwell never set
287
+ // dwellReached so it can't emit.
288
+ const endHover = (cause) => {
289
+ const el = trackedEl;
290
+ const reached = dwellReached;
291
+ const started = enterTime;
292
+ reset();
293
+ if (el && reached && cause !== "click") {
294
+ emit(el, started, now());
295
+ }
296
+ };
297
+ const startHover = (el) => {
298
+ reset();
299
+ trackedEl = el;
300
+ enterTime = now();
301
+ dwellReached = false;
302
+ dwellTimer = setTimeout(() => {
303
+ dwellTimer = null;
304
+ if (trackedEl && stillHovering(trackedEl)) {
305
+ dwellReached = true;
306
+ // Arm the hard cap so an indefinitely-parked cursor still emits once.
307
+ capTimer = setTimeout(() => {
308
+ capTimer = null;
309
+ endHover("cap");
310
+ }, Math.max(0, maxDwellMs - dwellMs));
311
+ }
312
+ else {
313
+ reset(); // entered then left before the dwell — no hover
314
+ }
315
+ }, dwellMs);
316
+ };
317
+ const onPointerOver = (event) => {
318
+ const target = event.target;
319
+ if (!(target instanceof Element))
320
+ return;
321
+ const affordanceEl = resolveHoverAffordance(target, maxDepth, strict);
322
+ if (!affordanceEl) {
323
+ if (trackedEl && !trackedEl.contains(target))
324
+ endHover("leave");
325
+ return;
326
+ }
327
+ if (affordanceEl === trackedEl)
328
+ return; // already tracking it
329
+ if (trackedEl)
330
+ endHover("switch"); // moved A→B: A's hover ends (may emit)
331
+ startHover(affordanceEl);
332
+ };
333
+ const onPointerOut = (event) => {
334
+ const related = event.relatedTarget;
335
+ if (trackedEl &&
336
+ (!(related instanceof Node) || !trackedEl.contains(related))) {
337
+ endHover("leave");
338
+ }
339
+ };
340
+ const onPointerDown = (event) => {
341
+ const target = event.target;
342
+ if (trackedEl &&
343
+ target instanceof Node &&
344
+ (trackedEl === target || trackedEl.contains(target))) {
345
+ endHover("click"); // suppress — this hover became a click
346
+ }
347
+ };
348
+ const onVisibility = () => {
349
+ if (document.visibilityState === "hidden")
350
+ endHover("flush");
351
+ };
352
+ const capture = { capture: true, passive: true };
353
+ document.addEventListener("pointerover", onPointerOver, capture);
354
+ document.addEventListener("pointerout", onPointerOut, capture);
355
+ document.addEventListener("pointerdown", onPointerDown, capture);
356
+ document.addEventListener("visibilitychange", onVisibility, true);
357
+ window.addEventListener("pagehide", onVisibility, true);
358
+ const teardown = () => {
359
+ endHover("flush");
360
+ reset();
361
+ document.removeEventListener("pointerover", onPointerOver, { capture: true });
362
+ document.removeEventListener("pointerout", onPointerOut, { capture: true });
363
+ document.removeEventListener("pointerdown", onPointerDown, { capture: true });
364
+ document.removeEventListener("visibilitychange", onVisibility, true);
365
+ window.removeEventListener("pagehide", onVisibility, true);
366
+ if (activeTeardown === teardown)
367
+ activeTeardown = null;
368
+ };
369
+ activeTeardown = teardown;
370
+ return teardown;
371
+ }
Binary file
Binary file
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { installHoverDetection } from "./hoverDetection";
3
+ // EventType.SailfishCustom === 25 (must match the backend's SailfishCustomEvent).
4
+ const SAILFISH_CUSTOM = 25;
5
+ function makeRecord(id = 42) {
6
+ return {
7
+ addSailfishEvent: vi.fn(),
8
+ mirror: { getId: vi.fn((_node) => id) },
9
+ };
10
+ }
11
+ // Affordance via the `title` attribute (cheapest path, no CSS needed in jsdom).
12
+ // We stub matches() so the `:hover` recheck is deterministic regardless of how
13
+ // jsdom handles the dynamic `:hover` pseudo-class.
14
+ function makeHoverable(hovering = true) {
15
+ const el = document.createElement("button");
16
+ el.setAttribute("title", "Help");
17
+ vi.spyOn(el, "matches").mockReturnValue(hovering);
18
+ document.body.appendChild(el);
19
+ return el;
20
+ }
21
+ const over = (el) => el.dispatchEvent(new Event("pointerover", { bubbles: true }));
22
+ const out = (el) => el.dispatchEvent(new Event("pointerout", { bubbles: true })); // null relatedTarget => "left"
23
+ const down = (el) => el.dispatchEvent(new Event("pointerdown", { bubbles: true }));
24
+ describe("installHoverDetection (v2)", () => {
25
+ let teardown = () => { };
26
+ beforeEach(() => {
27
+ vi.useFakeTimers();
28
+ document.body.innerHTML = "";
29
+ });
30
+ afterEach(() => {
31
+ teardown();
32
+ teardown = () => { };
33
+ vi.useRealTimers();
34
+ vi.restoreAllMocks();
35
+ });
36
+ it("emits once at hover-END with a duration, only after the dwell", () => {
37
+ const el = makeHoverable(true);
38
+ const record = makeRecord();
39
+ teardown = installHoverDetection(record, { dwellMs: 500 });
40
+ over(el);
41
+ vi.advanceTimersByTime(500); // dwell reached, but no end yet
42
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
43
+ vi.advanceTimersByTime(300); // dwell another 300ms
44
+ out(el); // hover ends -> emit
45
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
46
+ const [type, payload] = record.addSailfishEvent.mock.calls[0];
47
+ expect(type).toBe(SAILFISH_CUSTOM);
48
+ expect(payload.action).toBe("hover");
49
+ expect(payload.element_id).toBe(42);
50
+ expect(payload.duration_ms).toBeGreaterThanOrEqual(800);
51
+ // start + end are explicit epoch-ms; end − start === duration.
52
+ expect(typeof payload.started_at).toBe("number");
53
+ expect(typeof payload.ended_at).toBe("number");
54
+ expect(payload.ended_at - payload.started_at).toBe(payload.duration_ms);
55
+ });
56
+ it("does NOT emit if the cursor is no longer hovering at the dwell mark", () => {
57
+ const el = makeHoverable(false); // matches(":hover") === false at recheck
58
+ const record = makeRecord();
59
+ teardown = installHoverDetection(record, { dwellMs: 500 });
60
+ over(el);
61
+ vi.advanceTimersByTime(500);
62
+ out(el);
63
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
64
+ });
65
+ it("suppresses the hover when it ends in a click on the same element", () => {
66
+ const el = makeHoverable(true);
67
+ const record = makeRecord();
68
+ teardown = installHoverDetection(record, { dwellMs: 500 });
69
+ over(el);
70
+ vi.advanceTimersByTime(500); // dwell reached
71
+ down(el); // click -> suppress
72
+ out(el);
73
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
74
+ });
75
+ it("strict mode (default) ignores cursor:pointer-only elements", () => {
76
+ const div = document.createElement("div"); // no tooltip / menu / :hover rule
77
+ document.body.appendChild(div);
78
+ vi.spyOn(window, "getComputedStyle").mockReturnValue({
79
+ cursor: "pointer",
80
+ });
81
+ const record = makeRecord();
82
+ teardown = installHoverDetection(record, { dwellMs: 100 }); // strict default
83
+ over(div);
84
+ vi.advanceTimersByTime(200);
85
+ out(div);
86
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
87
+ });
88
+ it("non-strict mode accepts cursor:pointer as affordance", () => {
89
+ const div = document.createElement("div");
90
+ vi.spyOn(div, "matches").mockReturnValue(true); // :hover recheck passes
91
+ document.body.appendChild(div);
92
+ vi.spyOn(window, "getComputedStyle").mockReturnValue({
93
+ cursor: "pointer",
94
+ });
95
+ const record = makeRecord();
96
+ teardown = installHoverDetection(record, {
97
+ dwellMs: 100,
98
+ strictAffordance: false,
99
+ });
100
+ over(div);
101
+ vi.advanceTimersByTime(100);
102
+ out(div);
103
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
104
+ });
105
+ it("does not emit for elements without hover affordance", () => {
106
+ const div = document.createElement("div");
107
+ document.body.appendChild(div);
108
+ const record = makeRecord();
109
+ teardown = installHoverDetection(record, { dwellMs: 500 });
110
+ over(div);
111
+ vi.advanceTimersByTime(1000);
112
+ out(div);
113
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
114
+ });
115
+ it("dedups repeated hovers on the same element within the cooldown", () => {
116
+ const el = makeHoverable(true);
117
+ const record = makeRecord();
118
+ teardown = installHoverDetection(record, {
119
+ dwellMs: 100,
120
+ cooldownMs: 3000,
121
+ });
122
+ // First hover cycle -> emits.
123
+ over(el);
124
+ vi.advanceTimersByTime(100);
125
+ out(el);
126
+ // Second hover cycle on the same element, well within cooldown -> suppressed.
127
+ over(el);
128
+ vi.advanceTimersByTime(100);
129
+ out(el);
130
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
131
+ });
132
+ it("enforces the per-session cap", () => {
133
+ const record = makeRecord();
134
+ teardown = installHoverDetection(record, {
135
+ dwellMs: 100,
136
+ cooldownMs: 0,
137
+ maxPerSession: 1,
138
+ maxPerSecond: 1000,
139
+ });
140
+ const a = makeHoverable(true);
141
+ const b = makeHoverable(true);
142
+ record.mirror.getId.mockImplementation((n) => (n === a ? 1 : 2));
143
+ over(a);
144
+ vi.advanceTimersByTime(100);
145
+ out(a); // emits (1/1)
146
+ over(b);
147
+ vi.advanceTimersByTime(100);
148
+ out(b); // capped
149
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
150
+ });
151
+ it("emits on the hard dwell cap when the cursor is parked indefinitely", () => {
152
+ const el = makeHoverable(true);
153
+ const record = makeRecord();
154
+ teardown = installHoverDetection(record, {
155
+ dwellMs: 500,
156
+ maxDwellMs: 5000,
157
+ });
158
+ over(el);
159
+ vi.advanceTimersByTime(5000); // never leaves; cap fires
160
+ expect(record.addSailfishEvent).toHaveBeenCalledTimes(1);
161
+ expect(record.addSailfishEvent.mock.calls[0][1].duration_ms).toBeGreaterThanOrEqual(5000);
162
+ });
163
+ it("skips emission when the element is not in the rrweb mirror", () => {
164
+ const el = makeHoverable(true);
165
+ const record = makeRecord(-1); // getId returns -1 (unmapped)
166
+ teardown = installHoverDetection(record, { dwellMs: 100 });
167
+ over(el);
168
+ vi.advanceTimersByTime(100);
169
+ out(el);
170
+ expect(record.addSailfishEvent).not.toHaveBeenCalled();
171
+ });
172
+ it("is idempotent: re-installing tears down the prior detector (no double-emit)", () => {
173
+ const el = makeHoverable(true);
174
+ // First install, then a second install on the SAME document (simulates a
175
+ // recorder re-init). The second must reclaim the first's listeners so a
176
+ // single hover fires exactly once, not once per stacked detector.
177
+ const record1 = makeRecord();
178
+ installHoverDetection(record1, { dwellMs: 100 });
179
+ const record2 = makeRecord();
180
+ teardown = installHoverDetection(record2, { dwellMs: 100 });
181
+ over(el);
182
+ vi.advanceTimersByTime(100);
183
+ out(el);
184
+ expect(record1.addSailfishEvent).not.toHaveBeenCalled(); // old detector gone
185
+ expect(record2.addSailfishEvent).toHaveBeenCalledTimes(1); // only the new one
186
+ });
187
+ });
Binary file
Binary file
package/dist/index.js CHANGED
@@ -84,6 +84,14 @@ export const DEFAULT_CAPTURE_SETTINGS = {
84
84
  blockSelector: "",
85
85
  unmaskSelector: "",
86
86
  maskInputOptions: { password: true },
87
+ // Hover action (DIAGPLAT-1790): on by default; dwell ≈ when a tooltip/label
88
+ // would appear. All tunable per-account via the captureSettings API payload.
89
+ recordHover: true,
90
+ hoverDwellMs: 500,
91
+ hoverStrictAffordance: true,
92
+ hoverCooldownMs: 3000,
93
+ hoverMaxPerSession: 300,
94
+ hoverMaxPerSecond: 1,
87
95
  };
88
96
  export const DEFAULT_CONSOLE_RECORDING_SETTINGS = {
89
97
  level: ["info", "log", "warn", "error"],
@@ -644,6 +652,15 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = [], body
644
652
  return originalSend.apply(this, args);
645
653
  };
646
654
  }
655
+ // A rejected fetch carries no HTTP status — the browser hides it for CORS and
656
+ // network failures — so never fabricate one (DIAGPLAT-1699: a CORS-blocked 401
657
+ // was reported as 500). Prefer a status attached by an HTTP-lib wrapper
658
+ // (axios-style error.response.status), then the last status actually observed
659
+ // on the wire (e.g. the 400/403 that triggered the header-less retry which
660
+ // then threw), else 0 = "no response received", matching the XHR error path.
661
+ export function resolveFailedFetchStatus(error, observedStatus) {
662
+ return error?.response?.status ?? observedStatus ?? 0;
663
+ }
647
664
  // Updated fetch interceptor with exclusion handling
648
665
  function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureConfig = { captureStreamingResponseBody: true, captureResponseBodyMaxMb: 10, captureStreamPrefixKb: 64, captureStreamTimeoutMs: 10000 }, domainsToPropagateHeaderTo = []) {
649
666
  const originalFetch = window.fetch;
@@ -830,8 +847,12 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureC
830
847
  }
831
848
  // Mask authorization header for privacy (preserve scheme like "Bearer", mask the token)
832
849
  maskAuthorizationHeader(requestHeaders);
850
+ // Last HTTP status actually received, so the catch block can report it if
851
+ // a later step (e.g. the header-less retry) throws.
852
+ let observedStatus;
833
853
  try {
834
854
  let response = await injectHeader(target, thisArg, input, init, sessionId, urlAndStoredUuids.page_visit_uuid, networkUUID);
855
+ observedStatus = response.status;
835
856
  let isRetry = false;
836
857
  // Retry logic for 400/403 before logging finished event
837
858
  if (BAD_HTTP_STATUS.includes(response.status)) {
@@ -839,6 +860,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureC
839
860
  // Remove x-sf3-rid from captured headers since retry doesn't include it
840
861
  delete requestHeaders[xSf3RidHeader];
841
862
  response = await retryWithoutPropagateHeaders(target, thisArg, args, url);
863
+ observedStatus = response.status;
842
864
  isRetry = true;
843
865
  }
844
866
  const endTime = Date.now();
@@ -956,7 +978,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureC
956
978
  catch (error) {
957
979
  const endTime = Date.now();
958
980
  const success = false;
959
- const responseCode = error.response?.status || 500;
981
+ const responseCode = resolveFailedFetchStatus(error, observedStatus);
960
982
  const errorMessage = error.message || "Fetch request failed";
961
983
  if (error instanceof TypeError &&
962
984
  error?.message?.toLowerCase().includes(CORS_KEYWORD.toLowerCase())) {
package/dist/index.js.br CHANGED
Binary file
package/dist/index.js.gz CHANGED
Binary file
package/dist/recorder.cjs CHANGED
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const e = require("./chunks/index-qgM1hFXO.js");
4
- require("./chunks/patch2d-9voowhJ_.js"), exports.DEFAULT_CAPTURE_SETTINGS = e.DEFAULT_CAPTURE_SETTINGS, exports.DEFAULT_CONSOLE_RECORDING_SETTINGS = e.DEFAULT_CONSOLE_RECORDING_SETTINGS, exports.STORAGE_VERSION = e.STORAGE_VERSION, exports.addOrUpdateMetadata = e.addOrUpdateMetadata, exports.buildBatches = e.buildBatches, exports.clearStaleFuncSpanState = e.clearStaleFuncSpanState, exports.createSkipHeadersPropagationChecker = e.createSkipHeadersPropagationChecker, exports.createTriageAndIssueFromRecorder = e.createTriageAndIssueFromRecorder, exports.createTriageFromRecorder = e.createTriageFromRecorder, exports.disableFunctionSpanTracking = e.disableFunctionSpanTracking, exports.enableFunctionSpanTracking = e.enableFunctionSpanTracking, exports.ensureHrefCache = e.ensureHrefCache, exports.eventSize = e.eventSize, exports.fetchAndSendIp = e.fetchAndSendIp, exports.fetchCaptureSettings = e.fetchCaptureSettings, exports.fetchEngineeringTicketPlatformIntegrations = e.fetchEngineeringTicketPlatformIntegrations, exports.fetchFunctionSpanTrackingEnabled = e.fetchFunctionSpanTrackingEnabled, exports.flushBufferedEvents = e.flushBufferedEvents, exports.getBinaryDropCount = e.getBinaryDropCount, exports.getBufferedAmount = e.getBufferedAmount, exports.getCachedHref = e.getCachedHref, exports.getCachedHrefNoQuery = e.getCachedHrefNoQuery, exports.getFuncSpanHeader = e.getFuncSpanHeader, exports.getIdentifiedUser = e.getIdentifiedUser, exports.getOrSetSessionId = e.getOrSetSessionId, exports.getUrlAndStoredUuids = e.getUrlAndStoredUuids, exports.identify = e.identify, exports.initRecorder = e.initRecorder, exports.initializeConsolePlugin = e.initializeConsolePlugin, exports.initializeDomContentEvents = e.initializeDomContentEvents, exports.initializeFunctionSpanTrackingFromApi = e.initializeFunctionSpanTrackingFromApi, exports.initializePerformancePlugin = e.initializePerformancePlugin, exports.initializeRecording = e.initializeRecording, exports.initializeWebSocket = e.initializeWebSocket, exports.invalidateUrlCache = e.invalidateUrlCache, exports.isFunctionSpanTrackingEnabled = e.isFunctionSpanTrackingEnabled, exports.isTransportSaturated = e.isTransportSaturated, exports.maskCanvasDraws = e.maskCanvasDraws, exports.maskInputFn = e.maskInputFn, exports.matchUrlWithWildcard = e.matchUrlWithWildcard, Object.defineProperty(exports, "nowTimestamp", { enumerable: true, get: () => e.nowTimestamp }), exports.onNavigationChange = e.onNavigationChange, exports.openReportIssueModal = e.openReportIssueModal, exports.registerChart = e.registerChart, exports.registerFabricCanvas = e.registerFabricCanvas, exports.registerKonvaStage = e.registerKonvaStage, exports.registerPixiApp = e.registerPixiApp, exports.registerThreeScene = e.registerThreeScene, exports.requestTimeSync = e.requestTimeSync, exports.restoreFuncSpanState = e.restoreFuncSpanState, exports.sendDomainsToNotPropagateHeaderTo = e.sendDomainsToNotPropagateHeaderTo, exports.sendEvent = e.sendEvent, exports.sendGraphQLRequest = e.sendGraphQLRequest, exports.sendMessage = e.sendMessage, exports.startRecording = e.startRecording, exports.startRecordingSession = e.startRecordingSession, exports.toAbsoluteUrl = e.toAbsoluteUrl, exports.trackingEvent = e.trackingEvent, exports.withAppUrlMetadata = e.withAppUrlMetadata, exports.wsSendBinary = e.wsSendBinary;
3
+ const e = require("./chunks/index-Cp1vLB5Q.js");
4
+ require("./chunks/patch2d-9voowhJ_.js"), exports.DEFAULT_CAPTURE_SETTINGS = e.DEFAULT_CAPTURE_SETTINGS, exports.DEFAULT_CONSOLE_RECORDING_SETTINGS = e.DEFAULT_CONSOLE_RECORDING_SETTINGS, exports.STORAGE_VERSION = e.STORAGE_VERSION, exports.addOrUpdateMetadata = e.addOrUpdateMetadata, exports.buildBatches = e.buildBatches, exports.clearStaleFuncSpanState = e.clearStaleFuncSpanState, exports.createSkipHeadersPropagationChecker = e.createSkipHeadersPropagationChecker, exports.createTriageAndIssueFromRecorder = e.createTriageAndIssueFromRecorder, exports.createTriageFromRecorder = e.createTriageFromRecorder, exports.disableFunctionSpanTracking = e.disableFunctionSpanTracking, exports.enableFunctionSpanTracking = e.enableFunctionSpanTracking, exports.ensureHrefCache = e.ensureHrefCache, exports.eventSize = e.eventSize, exports.fetchAndSendIp = e.fetchAndSendIp, exports.fetchCaptureSettings = e.fetchCaptureSettings, exports.fetchEngineeringTicketPlatformIntegrations = e.fetchEngineeringTicketPlatformIntegrations, exports.fetchFunctionSpanTrackingEnabled = e.fetchFunctionSpanTrackingEnabled, exports.flushBufferedEvents = e.flushBufferedEvents, exports.getBinaryDropCount = e.getBinaryDropCount, exports.getBufferedAmount = e.getBufferedAmount, exports.getCachedHref = e.getCachedHref, exports.getCachedHrefNoQuery = e.getCachedHrefNoQuery, exports.getFuncSpanHeader = e.getFuncSpanHeader, exports.getIdentifiedUser = e.getIdentifiedUser, exports.getOrSetSessionId = e.getOrSetSessionId, exports.getUrlAndStoredUuids = e.getUrlAndStoredUuids, exports.identify = e.identify, exports.initRecorder = e.initRecorder, exports.initializeConsolePlugin = e.initializeConsolePlugin, exports.initializeDomContentEvents = e.initializeDomContentEvents, exports.initializeFunctionSpanTrackingFromApi = e.initializeFunctionSpanTrackingFromApi, exports.initializePerformancePlugin = e.initializePerformancePlugin, exports.initializeRecording = e.initializeRecording, exports.initializeWebSocket = e.initializeWebSocket, exports.invalidateUrlCache = e.invalidateUrlCache, exports.isFunctionSpanTrackingEnabled = e.isFunctionSpanTrackingEnabled, exports.isTransportSaturated = e.isTransportSaturated, exports.maskCanvasDraws = e.maskCanvasDraws, exports.maskInputFn = e.maskInputFn, exports.matchUrlWithWildcard = e.matchUrlWithWildcard, Object.defineProperty(exports, "nowTimestamp", { enumerable: true, get: () => e.nowTimestamp }), exports.onNavigationChange = e.onNavigationChange, exports.openReportIssueModal = e.openReportIssueModal, exports.registerChart = e.registerChart, exports.registerFabricCanvas = e.registerFabricCanvas, exports.registerKonvaStage = e.registerKonvaStage, exports.registerPixiApp = e.registerPixiApp, exports.registerThreeScene = e.registerThreeScene, exports.requestTimeSync = e.requestTimeSync, exports.resolveFailedFetchStatus = e.resolveFailedFetchStatus, exports.restoreFuncSpanState = e.restoreFuncSpanState, exports.sendDomainsToNotPropagateHeaderTo = e.sendDomainsToNotPropagateHeaderTo, exports.sendEvent = e.sendEvent, exports.sendGraphQLRequest = e.sendGraphQLRequest, exports.sendMessage = e.sendMessage, exports.startRecording = e.startRecording, exports.startRecordingSession = e.startRecordingSession, exports.toAbsoluteUrl = e.toAbsoluteUrl, exports.trackingEvent = e.trackingEvent, exports.withAppUrlMetadata = e.withAppUrlMetadata, exports.wsSendBinary = e.wsSendBinary;
Binary file
Binary file