@clue-ai/browser-sdk 0.0.1
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/README.md +100 -0
- package/dist/authoring/overlay.d.ts +12 -0
- package/dist/authoring/overlay.js +468 -0
- package/dist/authoring/recording.d.ts +125 -0
- package/dist/authoring/recording.js +481 -0
- package/dist/authoring/service-logo.d.ts +1 -0
- package/dist/authoring/service-logo.generated.d.ts +1 -0
- package/dist/authoring/service-logo.generated.js +3 -0
- package/dist/authoring/service-logo.js +1 -0
- package/dist/authoring/session.d.ts +23 -0
- package/dist/authoring/session.js +127 -0
- package/dist/authoring/surface.d.ts +11 -0
- package/dist/authoring/surface.js +63 -0
- package/dist/authoring/toolbar-constants.d.ts +23 -0
- package/dist/authoring/toolbar-constants.js +42 -0
- package/dist/authoring/toolbar-drag.d.ts +29 -0
- package/dist/authoring/toolbar-drag.js +270 -0
- package/dist/authoring/toolbar-view.d.ts +21 -0
- package/dist/authoring/toolbar-view.js +2584 -0
- package/dist/capture/action.d.ts +2 -0
- package/dist/capture/action.js +62 -0
- package/dist/capture/dom.d.ts +23 -0
- package/dist/capture/dom.js +329 -0
- package/dist/capture/drag.d.ts +2 -0
- package/dist/capture/drag.js +75 -0
- package/dist/capture/error.d.ts +2 -0
- package/dist/capture/error.js +193 -0
- package/dist/capture/form.d.ts +2 -0
- package/dist/capture/form.js +137 -0
- package/dist/capture/frustration.d.ts +2 -0
- package/dist/capture/frustration.js +171 -0
- package/dist/capture/input.d.ts +2 -0
- package/dist/capture/input.js +109 -0
- package/dist/capture/location.d.ts +10 -0
- package/dist/capture/location.js +42 -0
- package/dist/capture/navigation.d.ts +2 -0
- package/dist/capture/navigation.js +100 -0
- package/dist/capture/network.d.ts +13 -0
- package/dist/capture/network.js +903 -0
- package/dist/capture/page.d.ts +2 -0
- package/dist/capture/page.js +78 -0
- package/dist/capture/performance.d.ts +2 -0
- package/dist/capture/performance.js +268 -0
- package/dist/context/account.d.ts +12 -0
- package/dist/context/account.js +129 -0
- package/dist/context/environment.d.ts +42 -0
- package/dist/context/environment.js +208 -0
- package/dist/context/identity.d.ts +14 -0
- package/dist/context/identity.js +123 -0
- package/dist/context/session.d.ts +28 -0
- package/dist/context/session.js +155 -0
- package/dist/context/tab.d.ts +22 -0
- package/dist/context/tab.js +142 -0
- package/dist/context/trace.d.ts +32 -0
- package/dist/context/trace.js +65 -0
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +199 -0
- package/dist/core/constants.d.ts +43 -0
- package/dist/core/constants.js +109 -0
- package/dist/core/contracts.d.ts +58 -0
- package/dist/core/contracts.js +53 -0
- package/dist/core/sdk.d.ts +2 -0
- package/dist/core/sdk.js +831 -0
- package/dist/core/types.d.ts +413 -0
- package/dist/core/types.js +1 -0
- package/dist/core/usage-governor.d.ts +7 -0
- package/dist/core/usage-governor.js +127 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +36 -0
- package/dist/integrations/next-router.d.ts +16 -0
- package/dist/integrations/next-router.js +18 -0
- package/dist/integrations/react-router.d.ts +7 -0
- package/dist/integrations/react-router.js +37 -0
- package/dist/internal/metrics.d.ts +9 -0
- package/dist/internal/metrics.js +38 -0
- package/dist/normalize/builders.d.ts +15 -0
- package/dist/normalize/builders.js +786 -0
- package/dist/normalize/canonical.d.ts +13 -0
- package/dist/normalize/canonical.js +77 -0
- package/dist/normalize/event-id.d.ts +8 -0
- package/dist/normalize/event-id.js +39 -0
- package/dist/normalize/path-template.d.ts +1 -0
- package/dist/normalize/path-template.js +33 -0
- package/dist/privacy/local-minimization.d.ts +29 -0
- package/dist/privacy/local-minimization.js +88 -0
- package/dist/privacy/mask.d.ts +7 -0
- package/dist/privacy/mask.js +60 -0
- package/dist/privacy/parameter-snapshot.d.ts +14 -0
- package/dist/privacy/parameter-snapshot.js +206 -0
- package/dist/privacy/sanitize.d.ts +11 -0
- package/dist/privacy/sanitize.js +145 -0
- package/dist/privacy/schema-evidence.d.ts +20 -0
- package/dist/privacy/schema-evidence.js +238 -0
- package/dist/transport/batch.d.ts +37 -0
- package/dist/transport/batch.js +182 -0
- package/dist/transport/client.d.ts +61 -0
- package/dist/transport/client.js +267 -0
- package/dist/transport/queue.d.ts +22 -0
- package/dist/transport/queue.js +56 -0
- package/dist/transport/retry.d.ts +14 -0
- package/dist/transport/retry.js +46 -0
- package/package.json +38 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { buildLocalTextDescriptor } from "../privacy/local-minimization";
|
|
2
|
+
import { extractFieldName, extractFieldType, getElementAriaLabel, getElementClassList, getElementLabel, getElementPlaceholder, getElementRole, getElementSectionHeading, isEditableElement, resolveClickDisplayTarget, resolveStableKeyDescriptor, } from "./dom";
|
|
3
|
+
export const startActionCapture = (context) => {
|
|
4
|
+
if (typeof document === "undefined") {
|
|
5
|
+
return { stop: () => undefined };
|
|
6
|
+
}
|
|
7
|
+
const clickHandler = (event) => {
|
|
8
|
+
if (context.getConsent() === "denied") {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const target = event.target;
|
|
12
|
+
if (!(target instanceof Element)) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (context.shouldIgnoreElement?.(target)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const displayTarget = resolveClickDisplayTarget(target);
|
|
20
|
+
if (context.shouldIgnoreElement?.(displayTarget)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const stableKey = resolveStableKeyDescriptor(displayTarget);
|
|
24
|
+
context.onEvent({
|
|
25
|
+
type: "element_clicked",
|
|
26
|
+
occurredAtMs: Date.now(),
|
|
27
|
+
payload: {
|
|
28
|
+
viewId: context.getViewId(),
|
|
29
|
+
elementTag: displayTarget.tagName.toLowerCase(),
|
|
30
|
+
elementRole: getElementRole(displayTarget),
|
|
31
|
+
stableKey: stableKey.stableKey,
|
|
32
|
+
stableKeyQuality: stableKey.stableKeyQuality,
|
|
33
|
+
formKey: stableKey.formKey,
|
|
34
|
+
elementId: displayTarget.id || null,
|
|
35
|
+
elementClass: getElementClassList(displayTarget),
|
|
36
|
+
elementLabelMeta: buildLocalTextDescriptor(getElementLabel(displayTarget)),
|
|
37
|
+
ariaLabelMeta: buildLocalTextDescriptor(getElementAriaLabel(displayTarget)),
|
|
38
|
+
placeholderMeta: buildLocalTextDescriptor(getElementPlaceholder(displayTarget)),
|
|
39
|
+
clickTargetText: getElementLabel(displayTarget),
|
|
40
|
+
clickTargetAriaLabel: getElementAriaLabel(displayTarget),
|
|
41
|
+
clickTargetTitle: displayTarget.getAttribute("title") || null,
|
|
42
|
+
fieldName: extractFieldName(displayTarget),
|
|
43
|
+
fieldType: extractFieldType(displayTarget),
|
|
44
|
+
fieldDisplayLabel: getElementLabel(displayTarget),
|
|
45
|
+
fieldAriaLabel: getElementAriaLabel(displayTarget),
|
|
46
|
+
fieldPlaceholder: getElementPlaceholder(displayTarget),
|
|
47
|
+
isEditable: isEditableElement(displayTarget),
|
|
48
|
+
sectionHeading: getElementSectionHeading(displayTarget),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
context.onInternalError("capture/action:click", error);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
document.addEventListener("click", clickHandler, true);
|
|
57
|
+
return {
|
|
58
|
+
stop: () => {
|
|
59
|
+
document.removeEventListener("click", clickHandler, true);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const buildDomPath: (element: Element) => string;
|
|
2
|
+
export declare const getTextPreview: (element: Element) => string | null;
|
|
3
|
+
export declare const getElementClassList: (element: Element) => string[] | null;
|
|
4
|
+
export declare const getElementRole: (element: Element) => string | null;
|
|
5
|
+
export declare const getElementPlaceholder: (element: Element) => string | null;
|
|
6
|
+
export declare const getElementAriaLabel: (element: Element) => string | null;
|
|
7
|
+
export declare const getElementSectionHeading: (element: Element) => string | null;
|
|
8
|
+
export declare const getElementLabel: (element: Element) => string | null;
|
|
9
|
+
export declare const resolveClickDisplayTarget: (element: Element) => Element;
|
|
10
|
+
export type StableKeyQuality = "official" | "compat" | "structural";
|
|
11
|
+
export type StableKeyDescriptor = {
|
|
12
|
+
stableKey: string;
|
|
13
|
+
stableKeyQuality: StableKeyQuality;
|
|
14
|
+
formKey: string | null;
|
|
15
|
+
};
|
|
16
|
+
export declare const getFormKey: (element: Element) => string | null;
|
|
17
|
+
export declare const resolveStableKeyDescriptor: (element: Element) => StableKeyDescriptor;
|
|
18
|
+
export declare const getStableKey: (element: Element) => string;
|
|
19
|
+
export declare const extractFieldName: (element: Element) => string | null;
|
|
20
|
+
export declare const extractFieldType: (element: Element) => string | null;
|
|
21
|
+
export declare const findParentFormId: (element: Element) => string | null;
|
|
22
|
+
export declare const isEditableElement: (element: Element) => boolean;
|
|
23
|
+
export declare const getElementValue: (element: Element) => unknown;
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
export const buildDomPath = (element) => {
|
|
2
|
+
const segments = [];
|
|
3
|
+
let current = element;
|
|
4
|
+
while (current && segments.length < 8) {
|
|
5
|
+
const tag = current.tagName.toLowerCase();
|
|
6
|
+
const id = current.id ? `#${current.id}` : "";
|
|
7
|
+
const className = typeof current.className === "string" && current.className.trim()
|
|
8
|
+
? `.${current.className.trim().split(/\s+/).slice(0, 2).join(".")}`
|
|
9
|
+
: "";
|
|
10
|
+
segments.unshift(`${tag}${id}${className}`);
|
|
11
|
+
current = current.parentElement;
|
|
12
|
+
}
|
|
13
|
+
return segments.join(" > ");
|
|
14
|
+
};
|
|
15
|
+
export const getTextPreview = (element) => {
|
|
16
|
+
const text = element.textContent?.trim() ?? "";
|
|
17
|
+
if (!text) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return text.slice(0, 120);
|
|
21
|
+
};
|
|
22
|
+
export const getElementClassList = (element) => {
|
|
23
|
+
if (typeof element.className !== "string") {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const classList = element.className.trim().split(/\s+/).filter(Boolean);
|
|
27
|
+
return classList.length > 0 ? classList : null;
|
|
28
|
+
};
|
|
29
|
+
export const getElementRole = (element) => {
|
|
30
|
+
return element.getAttribute("role") || null;
|
|
31
|
+
};
|
|
32
|
+
export const getElementPlaceholder = (element) => {
|
|
33
|
+
if (element instanceof HTMLInputElement ||
|
|
34
|
+
element instanceof HTMLTextAreaElement) {
|
|
35
|
+
return element.placeholder || null;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
export const getElementAriaLabel = (element) => {
|
|
40
|
+
return element.getAttribute("aria-label") || null;
|
|
41
|
+
};
|
|
42
|
+
const SECTION_CONTAINER_SELECTOR = [
|
|
43
|
+
"section",
|
|
44
|
+
"article",
|
|
45
|
+
"aside",
|
|
46
|
+
"nav",
|
|
47
|
+
"main",
|
|
48
|
+
"form",
|
|
49
|
+
"fieldset",
|
|
50
|
+
"dialog",
|
|
51
|
+
"[role='dialog']",
|
|
52
|
+
"[role='region']",
|
|
53
|
+
].join(",");
|
|
54
|
+
const SECTION_HEADING_SELECTOR = [
|
|
55
|
+
"h1",
|
|
56
|
+
"h2",
|
|
57
|
+
"h3",
|
|
58
|
+
"h4",
|
|
59
|
+
"h5",
|
|
60
|
+
"h6",
|
|
61
|
+
"legend",
|
|
62
|
+
].join(",");
|
|
63
|
+
const readSectionHeadingLabel = (container) => {
|
|
64
|
+
const labelledBy = container.getAttribute("aria-labelledby");
|
|
65
|
+
if (labelledBy) {
|
|
66
|
+
const labelNode = container.ownerDocument?.getElementById(labelledBy);
|
|
67
|
+
if (labelNode instanceof Element) {
|
|
68
|
+
const text = getTextPreview(labelNode);
|
|
69
|
+
if (text) {
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const headingNode = container.querySelector(SECTION_HEADING_SELECTOR);
|
|
75
|
+
if (headingNode instanceof Element) {
|
|
76
|
+
const text = getTextPreview(headingNode);
|
|
77
|
+
if (text) {
|
|
78
|
+
return text;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return (container.getAttribute("aria-label") ||
|
|
82
|
+
container.getAttribute("title") ||
|
|
83
|
+
null);
|
|
84
|
+
};
|
|
85
|
+
export const getElementSectionHeading = (element) => {
|
|
86
|
+
let container = element.closest(SECTION_CONTAINER_SELECTOR);
|
|
87
|
+
while (container) {
|
|
88
|
+
const label = readSectionHeadingLabel(container);
|
|
89
|
+
if (label) {
|
|
90
|
+
return label;
|
|
91
|
+
}
|
|
92
|
+
container = container.parentElement?.closest(SECTION_CONTAINER_SELECTOR) ?? null;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
};
|
|
96
|
+
export const getElementLabel = (element) => {
|
|
97
|
+
if (element instanceof HTMLInputElement ||
|
|
98
|
+
element instanceof HTMLTextAreaElement ||
|
|
99
|
+
element instanceof HTMLSelectElement) {
|
|
100
|
+
const label = element.labels?.[0]?.textContent?.trim();
|
|
101
|
+
return label || null;
|
|
102
|
+
}
|
|
103
|
+
const ownText = Array.from(element.childNodes)
|
|
104
|
+
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
|
105
|
+
.map((node) => node.textContent?.trim() ?? "")
|
|
106
|
+
.filter((value) => value.length > 0)
|
|
107
|
+
.join(" ")
|
|
108
|
+
.trim();
|
|
109
|
+
if (ownText) {
|
|
110
|
+
return ownText.slice(0, 120);
|
|
111
|
+
}
|
|
112
|
+
const isInteractiveDisplayTarget = element instanceof HTMLButtonElement ||
|
|
113
|
+
element instanceof HTMLAnchorElement ||
|
|
114
|
+
element.getAttribute("role") === "button" ||
|
|
115
|
+
element.getAttribute("role") === "link";
|
|
116
|
+
const descendantCandidates = Array.from(element.querySelectorAll("label, p, span, strong, em, small, button, a, [role='button'], [role='link']"))
|
|
117
|
+
.map((candidate) => candidate.textContent?.trim() ?? "")
|
|
118
|
+
.map((value) => value.replace(/\s+/g, " ").trim())
|
|
119
|
+
.filter((value) => value.length > 0 && value.length <= 80);
|
|
120
|
+
if (isInteractiveDisplayTarget && descendantCandidates.length > 0) {
|
|
121
|
+
return descendantCandidates[0] ?? null;
|
|
122
|
+
}
|
|
123
|
+
if (descendantCandidates.length > 0) {
|
|
124
|
+
descendantCandidates.sort((left, right) => {
|
|
125
|
+
const leftWordCount = left.split(/\s+/).length;
|
|
126
|
+
const rightWordCount = right.split(/\s+/).length;
|
|
127
|
+
if (leftWordCount !== rightWordCount) {
|
|
128
|
+
return leftWordCount - rightWordCount;
|
|
129
|
+
}
|
|
130
|
+
return left.length - right.length;
|
|
131
|
+
});
|
|
132
|
+
return descendantCandidates[0] ?? null;
|
|
133
|
+
}
|
|
134
|
+
return getTextPreview(element);
|
|
135
|
+
};
|
|
136
|
+
const CLICKABLE_DISPLAY_SELECTOR = [
|
|
137
|
+
"a[href]",
|
|
138
|
+
"button",
|
|
139
|
+
"input",
|
|
140
|
+
"textarea",
|
|
141
|
+
"select",
|
|
142
|
+
"[role='button']",
|
|
143
|
+
"[role='link']",
|
|
144
|
+
].join(",");
|
|
145
|
+
export const resolveClickDisplayTarget = (element) => {
|
|
146
|
+
const matched = element.closest(CLICKABLE_DISPLAY_SELECTOR);
|
|
147
|
+
return matched ?? element;
|
|
148
|
+
};
|
|
149
|
+
const SAFE_FALLBACK_STABLE_IDENTIFIER = /^[a-z0-9._:-]{1,80}$/i;
|
|
150
|
+
const toSafeFallbackStableSegment = (value) => {
|
|
151
|
+
if (!value) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const normalized = value.trim();
|
|
155
|
+
if (!normalized || !SAFE_FALLBACK_STABLE_IDENTIFIER.test(normalized)) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return normalized.toLowerCase();
|
|
159
|
+
};
|
|
160
|
+
const buildCoarsePosition = (element) => {
|
|
161
|
+
const segments = [];
|
|
162
|
+
let current = element;
|
|
163
|
+
let depth = 0;
|
|
164
|
+
while (current && depth < 4) {
|
|
165
|
+
const parent = current.parentElement;
|
|
166
|
+
if (!parent) {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
const siblings = Array.from(parent.children);
|
|
170
|
+
const currentTagName = current.tagName;
|
|
171
|
+
const sameTagSiblings = siblings.filter((child) => child.tagName === currentTagName);
|
|
172
|
+
const positionIndex = siblings.indexOf(current) + 1;
|
|
173
|
+
const sameTagIndex = sameTagSiblings.indexOf(current) + 1;
|
|
174
|
+
segments.unshift(`p${positionIndex}-t${sameTagIndex}`);
|
|
175
|
+
current = parent;
|
|
176
|
+
depth += 1;
|
|
177
|
+
}
|
|
178
|
+
if (segments.length === 0) {
|
|
179
|
+
return "root";
|
|
180
|
+
}
|
|
181
|
+
return segments.join("/");
|
|
182
|
+
};
|
|
183
|
+
const buildStructuralStableKey = (element) => {
|
|
184
|
+
const tag = element.tagName.toLowerCase();
|
|
185
|
+
const role = toSafeFallbackStableSegment(getElementRole(element)) || "none";
|
|
186
|
+
const inputType = (element instanceof HTMLInputElement && element.type) ||
|
|
187
|
+
(element instanceof HTMLTextAreaElement && "textarea") ||
|
|
188
|
+
(element instanceof HTMLSelectElement && "select") ||
|
|
189
|
+
(element instanceof HTMLElement &&
|
|
190
|
+
element.isContentEditable &&
|
|
191
|
+
"contenteditable") ||
|
|
192
|
+
"na";
|
|
193
|
+
return `struct:${tag}:${role}:${inputType}:${buildCoarsePosition(element)}`;
|
|
194
|
+
};
|
|
195
|
+
export const getFormKey = (element) => {
|
|
196
|
+
return toSafeFallbackStableSegment(findParentFormId(element));
|
|
197
|
+
};
|
|
198
|
+
export const resolveStableKeyDescriptor = (element) => {
|
|
199
|
+
const dataTestId = toSafeFallbackStableSegment(element.getAttribute("data-testid"));
|
|
200
|
+
if (dataTestId) {
|
|
201
|
+
return {
|
|
202
|
+
stableKey: `dtid:${dataTestId}`,
|
|
203
|
+
stableKeyQuality: "compat",
|
|
204
|
+
formKey: getFormKey(element),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const dataQa = toSafeFallbackStableSegment(element.getAttribute("data-qa"));
|
|
208
|
+
if (dataQa) {
|
|
209
|
+
return {
|
|
210
|
+
stableKey: `dqa:${dataQa}`,
|
|
211
|
+
stableKeyQuality: "compat",
|
|
212
|
+
formKey: getFormKey(element),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const safeName = toSafeFallbackStableSegment(element.getAttribute("name"));
|
|
216
|
+
if (safeName) {
|
|
217
|
+
return {
|
|
218
|
+
stableKey: `name:${safeName}`,
|
|
219
|
+
stableKeyQuality: "structural",
|
|
220
|
+
formKey: getFormKey(element),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const safeAriaLabel = toSafeFallbackStableSegment(getElementAriaLabel(element));
|
|
224
|
+
if (safeAriaLabel) {
|
|
225
|
+
return {
|
|
226
|
+
stableKey: `aria:${safeAriaLabel}`,
|
|
227
|
+
stableKeyQuality: "structural",
|
|
228
|
+
formKey: getFormKey(element),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const safeId = toSafeFallbackStableSegment(element.id);
|
|
232
|
+
if (safeId) {
|
|
233
|
+
return {
|
|
234
|
+
stableKey: `id:${safeId}`,
|
|
235
|
+
stableKeyQuality: "structural",
|
|
236
|
+
formKey: getFormKey(element),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
stableKey: buildStructuralStableKey(element),
|
|
241
|
+
stableKeyQuality: "structural",
|
|
242
|
+
formKey: getFormKey(element),
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
export const getStableKey = (element) => {
|
|
246
|
+
return resolveStableKeyDescriptor(element).stableKey;
|
|
247
|
+
};
|
|
248
|
+
export const extractFieldName = (element) => {
|
|
249
|
+
if (element instanceof HTMLInputElement ||
|
|
250
|
+
element instanceof HTMLTextAreaElement ||
|
|
251
|
+
element instanceof HTMLSelectElement) {
|
|
252
|
+
return (element.getAttribute("name") ||
|
|
253
|
+
element.getAttribute("id") ||
|
|
254
|
+
element.getAttribute("aria-label") ||
|
|
255
|
+
null);
|
|
256
|
+
}
|
|
257
|
+
if (element instanceof HTMLElement && element.isContentEditable) {
|
|
258
|
+
return (element.getAttribute("data-field-name") ||
|
|
259
|
+
element.getAttribute("aria-label") ||
|
|
260
|
+
element.getAttribute("id") ||
|
|
261
|
+
null);
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
};
|
|
265
|
+
export const extractFieldType = (element) => {
|
|
266
|
+
if (element instanceof HTMLInputElement) {
|
|
267
|
+
return element.type || "input";
|
|
268
|
+
}
|
|
269
|
+
if (element instanceof HTMLTextAreaElement) {
|
|
270
|
+
return "textarea";
|
|
271
|
+
}
|
|
272
|
+
if (element instanceof HTMLSelectElement) {
|
|
273
|
+
return "select";
|
|
274
|
+
}
|
|
275
|
+
if (element instanceof HTMLElement && element.isContentEditable) {
|
|
276
|
+
return "contenteditable";
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
};
|
|
280
|
+
export const findParentFormId = (element) => {
|
|
281
|
+
if (element instanceof HTMLInputElement ||
|
|
282
|
+
element instanceof HTMLTextAreaElement ||
|
|
283
|
+
element instanceof HTMLSelectElement) {
|
|
284
|
+
const form = element.form;
|
|
285
|
+
return form ? form.id || form.getAttribute("name") || null : null;
|
|
286
|
+
}
|
|
287
|
+
if (element instanceof HTMLElement) {
|
|
288
|
+
const form = element.closest("form");
|
|
289
|
+
return form instanceof HTMLFormElement
|
|
290
|
+
? form.id || form.getAttribute("name") || null
|
|
291
|
+
: null;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
};
|
|
295
|
+
export const isEditableElement = (element) => {
|
|
296
|
+
return Boolean(element instanceof HTMLInputElement ||
|
|
297
|
+
element instanceof HTMLTextAreaElement ||
|
|
298
|
+
element instanceof HTMLSelectElement ||
|
|
299
|
+
(element instanceof HTMLElement && element.isContentEditable));
|
|
300
|
+
};
|
|
301
|
+
export const getElementValue = (element) => {
|
|
302
|
+
if (element instanceof HTMLInputElement) {
|
|
303
|
+
if (element.type === "checkbox") {
|
|
304
|
+
return element.checked;
|
|
305
|
+
}
|
|
306
|
+
if (element.type === "radio") {
|
|
307
|
+
return element.checked ? element.value : null;
|
|
308
|
+
}
|
|
309
|
+
if (element.type === "file") {
|
|
310
|
+
return {
|
|
311
|
+
fileCount: element.files?.length ?? 0,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return element.value;
|
|
315
|
+
}
|
|
316
|
+
if (element instanceof HTMLTextAreaElement) {
|
|
317
|
+
return element.value;
|
|
318
|
+
}
|
|
319
|
+
if (element instanceof HTMLSelectElement) {
|
|
320
|
+
if (element.multiple) {
|
|
321
|
+
return Array.from(element.selectedOptions).map((option) => option.value);
|
|
322
|
+
}
|
|
323
|
+
return element.value;
|
|
324
|
+
}
|
|
325
|
+
if (element instanceof HTMLElement && element.isContentEditable) {
|
|
326
|
+
return element.textContent?.trim() ?? null;
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { buildLocalValueDescriptor } from "../privacy/local-minimization";
|
|
2
|
+
import { extractFieldName, extractFieldType, getElementAriaLabel, getElementLabel, getElementPlaceholder, getElementRole, getElementSectionHeading, resolveStableKeyDescriptor, } from "./dom";
|
|
3
|
+
const readDragPayload = (event) => {
|
|
4
|
+
const dataTransfer = event.dataTransfer;
|
|
5
|
+
if (!dataTransfer) {
|
|
6
|
+
return {
|
|
7
|
+
itemCount: 0,
|
|
8
|
+
fileCount: 0,
|
|
9
|
+
dragTypes: [],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const itemCount = dataTransfer.items?.length ?? 0;
|
|
13
|
+
const fileCount = dataTransfer.files?.length ?? 0;
|
|
14
|
+
const dragTypes = Array.from(dataTransfer.types ?? []).filter(Boolean);
|
|
15
|
+
return {
|
|
16
|
+
itemCount,
|
|
17
|
+
fileCount,
|
|
18
|
+
dragTypes,
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export const startDragCapture = (context) => {
|
|
22
|
+
if (typeof document === "undefined") {
|
|
23
|
+
return { stop: () => undefined };
|
|
24
|
+
}
|
|
25
|
+
const dropHandler = (event) => {
|
|
26
|
+
if (context.getConsent() === "denied") {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const target = event.target;
|
|
30
|
+
if (!(target instanceof Element)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (context.shouldIgnoreElement?.(target)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const stableKey = resolveStableKeyDescriptor(target);
|
|
38
|
+
const dragPayload = readDragPayload(event);
|
|
39
|
+
context.onEvent({
|
|
40
|
+
type: "drag_drop_completed",
|
|
41
|
+
occurredAtMs: Date.now(),
|
|
42
|
+
payload: {
|
|
43
|
+
viewId: context.getViewId(),
|
|
44
|
+
elementTag: target.tagName.toLowerCase(),
|
|
45
|
+
elementRole: getElementRole(target),
|
|
46
|
+
stableKey: stableKey.stableKey,
|
|
47
|
+
stableKeyQuality: stableKey.stableKeyQuality,
|
|
48
|
+
formKey: stableKey.formKey,
|
|
49
|
+
fieldNameMeta: buildLocalValueDescriptor(extractFieldName(target)),
|
|
50
|
+
elementLabelMeta: buildLocalValueDescriptor(getElementLabel(target)),
|
|
51
|
+
ariaLabelMeta: buildLocalValueDescriptor(getElementAriaLabel(target)),
|
|
52
|
+
placeholderMeta: buildLocalValueDescriptor(getElementPlaceholder(target)),
|
|
53
|
+
fieldName: extractFieldName(target),
|
|
54
|
+
fieldType: extractFieldType(target),
|
|
55
|
+
fieldDisplayLabel: getElementLabel(target),
|
|
56
|
+
fieldAriaLabel: getElementAriaLabel(target),
|
|
57
|
+
fieldPlaceholder: getElementPlaceholder(target),
|
|
58
|
+
sectionHeading: getElementSectionHeading(target),
|
|
59
|
+
dragItemCount: dragPayload.itemCount,
|
|
60
|
+
fileCount: dragPayload.fileCount,
|
|
61
|
+
dragTypes: dragPayload.dragTypes,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
context.onInternalError("capture/drag:drop", error);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
document.addEventListener("drop", dropHandler, true);
|
|
70
|
+
return {
|
|
71
|
+
stop: () => {
|
|
72
|
+
document.removeEventListener("drop", dropHandler, true);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const ERROR_BURST_DEBOUNCE_MS = 250;
|
|
2
|
+
const hashString = (value) => {
|
|
3
|
+
let hash = 2166136261;
|
|
4
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
5
|
+
hash ^= value.charCodeAt(index);
|
|
6
|
+
hash = Math.imul(hash, 16777619);
|
|
7
|
+
}
|
|
8
|
+
return `err_${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
9
|
+
};
|
|
10
|
+
const sourceFileHintFromValue = (value) => {
|
|
11
|
+
if (!value) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const resolved = new URL(value, globalThis.location?.origin);
|
|
16
|
+
const segments = resolved.pathname.split("/").filter(Boolean);
|
|
17
|
+
return segments[segments.length - 1] ?? null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
const normalized = value.split(/[?#]/, 1)[0] ?? value;
|
|
21
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
22
|
+
return segments[segments.length - 1] ?? (normalized || null);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const buildFingerprint = (input) => {
|
|
26
|
+
const firstStackLine = input.stack
|
|
27
|
+
?.split("\n")
|
|
28
|
+
.map((line) => line.trim())
|
|
29
|
+
.find(Boolean) ?? "";
|
|
30
|
+
return hashString([
|
|
31
|
+
input.type,
|
|
32
|
+
input.errorType ?? "",
|
|
33
|
+
input.message ?? "",
|
|
34
|
+
sourceFileHintFromValue(input.sourceFile) ?? "",
|
|
35
|
+
input.lineNumber ?? "",
|
|
36
|
+
input.columnNumber ?? "",
|
|
37
|
+
firstStackLine,
|
|
38
|
+
].join("|"));
|
|
39
|
+
};
|
|
40
|
+
export const startErrorCapture = (context) => {
|
|
41
|
+
if (typeof window === "undefined") {
|
|
42
|
+
return { stop: () => undefined };
|
|
43
|
+
}
|
|
44
|
+
const pendingByFingerprint = new Map();
|
|
45
|
+
const flushFingerprint = (fingerprint) => {
|
|
46
|
+
const bucket = pendingByFingerprint.get(fingerprint);
|
|
47
|
+
if (!bucket) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (bucket.timer) {
|
|
51
|
+
clearTimeout(bucket.timer);
|
|
52
|
+
}
|
|
53
|
+
context.onEvent({
|
|
54
|
+
type: bucket.event.type,
|
|
55
|
+
occurredAtMs: bucket.lastOccurredAtMs,
|
|
56
|
+
payload: {
|
|
57
|
+
...bucket.event.payload,
|
|
58
|
+
repeatCount: bucket.repeatCount,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
pendingByFingerprint.delete(fingerprint);
|
|
62
|
+
};
|
|
63
|
+
const scheduleFlush = (fingerprint) => {
|
|
64
|
+
const bucket = pendingByFingerprint.get(fingerprint);
|
|
65
|
+
if (!bucket) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (bucket.timer) {
|
|
69
|
+
clearTimeout(bucket.timer);
|
|
70
|
+
}
|
|
71
|
+
bucket.timer = setTimeout(() => {
|
|
72
|
+
flushFingerprint(fingerprint);
|
|
73
|
+
}, ERROR_BURST_DEBOUNCE_MS);
|
|
74
|
+
};
|
|
75
|
+
const queueErrorEnvelope = (type, occurredAtMs, payload) => {
|
|
76
|
+
const fingerprint = String(payload.errorFingerprint ?? "");
|
|
77
|
+
if (!fingerprint) {
|
|
78
|
+
context.onEvent({
|
|
79
|
+
type,
|
|
80
|
+
occurredAtMs,
|
|
81
|
+
payload: {
|
|
82
|
+
...payload,
|
|
83
|
+
repeatCount: 1,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const existing = pendingByFingerprint.get(fingerprint);
|
|
89
|
+
if (existing) {
|
|
90
|
+
existing.repeatCount += 1;
|
|
91
|
+
existing.lastOccurredAtMs = occurredAtMs;
|
|
92
|
+
existing.event.payload = {
|
|
93
|
+
...existing.event.payload,
|
|
94
|
+
...payload,
|
|
95
|
+
};
|
|
96
|
+
scheduleFlush(fingerprint);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
pendingByFingerprint.set(fingerprint, {
|
|
100
|
+
event: {
|
|
101
|
+
type,
|
|
102
|
+
occurredAtMs,
|
|
103
|
+
payload,
|
|
104
|
+
},
|
|
105
|
+
repeatCount: 1,
|
|
106
|
+
lastOccurredAtMs: occurredAtMs,
|
|
107
|
+
timer: null,
|
|
108
|
+
});
|
|
109
|
+
scheduleFlush(fingerprint);
|
|
110
|
+
};
|
|
111
|
+
const errorHandler = (event) => {
|
|
112
|
+
if (context.getConsent() === "denied") {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const occurredAtMs = Date.now();
|
|
117
|
+
const sourceFile = event.filename || null;
|
|
118
|
+
const fingerprint = buildFingerprint({
|
|
119
|
+
type: "frontend_error",
|
|
120
|
+
errorType: event.error?.name || event.type || "Error",
|
|
121
|
+
message: event.message || null,
|
|
122
|
+
sourceFile,
|
|
123
|
+
lineNumber: event.lineno || undefined,
|
|
124
|
+
columnNumber: event.colno || undefined,
|
|
125
|
+
stack: event.error && typeof event.error.stack === "string"
|
|
126
|
+
? event.error.stack
|
|
127
|
+
: null,
|
|
128
|
+
});
|
|
129
|
+
queueErrorEnvelope("frontend_error", occurredAtMs, {
|
|
130
|
+
viewId: context.getViewId(),
|
|
131
|
+
errorType: event.error?.name || event.type || "Error",
|
|
132
|
+
errorFingerprint: fingerprint,
|
|
133
|
+
sourceFileHint: sourceFileHintFromValue(sourceFile),
|
|
134
|
+
message: event.message || null,
|
|
135
|
+
stack: event.error && typeof event.error.stack === "string"
|
|
136
|
+
? event.error.stack
|
|
137
|
+
: null,
|
|
138
|
+
sourceFile,
|
|
139
|
+
lineNumber: event.lineno || undefined,
|
|
140
|
+
columnNumber: event.colno || undefined,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
context.onInternalError("capture/error:error", error);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const rejectionHandler = (event) => {
|
|
148
|
+
if (context.getConsent() === "denied") {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const reason = event.reason;
|
|
153
|
+
const message = reason instanceof Error
|
|
154
|
+
? reason.message
|
|
155
|
+
: typeof reason === "string"
|
|
156
|
+
? reason
|
|
157
|
+
: null;
|
|
158
|
+
const stack = reason instanceof Error ? (reason.stack ?? null) : null;
|
|
159
|
+
const errorType = reason instanceof Error ? reason.name : "PromiseRejection";
|
|
160
|
+
const occurredAtMs = Date.now();
|
|
161
|
+
const fingerprint = buildFingerprint({
|
|
162
|
+
type: "unhandled_rejection",
|
|
163
|
+
errorType,
|
|
164
|
+
message,
|
|
165
|
+
sourceFile: null,
|
|
166
|
+
stack,
|
|
167
|
+
});
|
|
168
|
+
queueErrorEnvelope("unhandled_rejection", occurredAtMs, {
|
|
169
|
+
viewId: context.getViewId(),
|
|
170
|
+
errorType,
|
|
171
|
+
errorFingerprint: fingerprint,
|
|
172
|
+
sourceFileHint: null,
|
|
173
|
+
message,
|
|
174
|
+
stack,
|
|
175
|
+
sourceFile: null,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
context.onInternalError("capture/error:unhandledrejection", error);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
window.addEventListener("error", errorHandler);
|
|
183
|
+
window.addEventListener("unhandledrejection", rejectionHandler);
|
|
184
|
+
return {
|
|
185
|
+
stop: () => {
|
|
186
|
+
for (const fingerprint of pendingByFingerprint.keys()) {
|
|
187
|
+
flushFingerprint(fingerprint);
|
|
188
|
+
}
|
|
189
|
+
window.removeEventListener("error", errorHandler);
|
|
190
|
+
window.removeEventListener("unhandledrejection", rejectionHandler);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
};
|