@gemx-dev/clarity-js 0.8.39
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 +1 -0
- package/build/clarity.extended.js +1 -0
- package/build/clarity.insight.js +1 -0
- package/build/clarity.js +6433 -0
- package/build/clarity.livechat.js +1 -0
- package/build/clarity.min.js +1 -0
- package/build/clarity.module.js +6429 -0
- package/build/clarity.performance.js +1 -0
- package/build/dynamic/clarity.crisp.js +1 -0
- package/build/dynamic/clarity.tidio.js +1 -0
- package/package.json +55 -0
- package/rollup.config.ts +161 -0
- package/src/clarity.ts +71 -0
- package/src/core/api.ts +8 -0
- package/src/core/config.ts +29 -0
- package/src/core/copy.ts +3 -0
- package/src/core/dynamic.ts +57 -0
- package/src/core/event.ts +53 -0
- package/src/core/hash.ts +19 -0
- package/src/core/history.ts +71 -0
- package/src/core/index.ts +81 -0
- package/src/core/measure.ts +19 -0
- package/src/core/report.ts +35 -0
- package/src/core/scrub.ts +202 -0
- package/src/core/task.ts +181 -0
- package/src/core/throttle.ts +46 -0
- package/src/core/time.ts +26 -0
- package/src/core/timeout.ts +10 -0
- package/src/core/version.ts +2 -0
- package/src/data/baseline.ts +162 -0
- package/src/data/compress.ts +31 -0
- package/src/data/consent.ts +75 -0
- package/src/data/custom.ts +23 -0
- package/src/data/dimension.ts +53 -0
- package/src/data/encode.ts +155 -0
- package/src/data/envelope.ts +53 -0
- package/src/data/extract.ts +211 -0
- package/src/data/index.ts +50 -0
- package/src/data/limit.ts +44 -0
- package/src/data/metadata.ts +408 -0
- package/src/data/metric.ts +51 -0
- package/src/data/ping.ts +36 -0
- package/src/data/signal.ts +30 -0
- package/src/data/summary.ts +34 -0
- package/src/data/token.ts +39 -0
- package/src/data/upgrade.ts +44 -0
- package/src/data/upload.ts +333 -0
- package/src/data/variable.ts +84 -0
- package/src/diagnostic/encode.ts +40 -0
- package/src/diagnostic/fraud.ts +37 -0
- package/src/diagnostic/index.ts +13 -0
- package/src/diagnostic/internal.ts +28 -0
- package/src/diagnostic/script.ts +35 -0
- package/src/dynamic/agent/blank.ts +2 -0
- package/src/dynamic/agent/crisp.ts +40 -0
- package/src/dynamic/agent/encode.ts +25 -0
- package/src/dynamic/agent/index.ts +8 -0
- package/src/dynamic/agent/livechat.ts +58 -0
- package/src/dynamic/agent/tidio.ts +44 -0
- package/src/global.ts +6 -0
- package/src/index.ts +9 -0
- package/src/insight/blank.ts +14 -0
- package/src/insight/encode.ts +61 -0
- package/src/insight/snapshot.ts +115 -0
- package/src/interaction/change.ts +38 -0
- package/src/interaction/click.ts +163 -0
- package/src/interaction/clipboard.ts +32 -0
- package/src/interaction/encode.ts +207 -0
- package/src/interaction/focus.ts +25 -0
- package/src/interaction/index.ts +60 -0
- package/src/interaction/input.ts +58 -0
- package/src/interaction/pointer.ts +137 -0
- package/src/interaction/resize.ts +50 -0
- package/src/interaction/scroll.ts +129 -0
- package/src/interaction/selection.ts +66 -0
- package/src/interaction/submit.ts +30 -0
- package/src/interaction/timeline.ts +69 -0
- package/src/interaction/unload.ts +26 -0
- package/src/interaction/visibility.ts +28 -0
- package/src/layout/animation.ts +133 -0
- package/src/layout/custom.ts +43 -0
- package/src/layout/discover.ts +31 -0
- package/src/layout/document.ts +46 -0
- package/src/layout/dom.ts +439 -0
- package/src/layout/encode.ts +154 -0
- package/src/layout/index.ts +42 -0
- package/src/layout/mutation.ts +412 -0
- package/src/layout/node.ts +294 -0
- package/src/layout/offset.ts +19 -0
- package/src/layout/region.ts +151 -0
- package/src/layout/schema.ts +63 -0
- package/src/layout/selector.ts +82 -0
- package/src/layout/style.ts +160 -0
- package/src/layout/target.ts +32 -0
- package/src/layout/traverse.ts +28 -0
- package/src/performance/blank.ts +10 -0
- package/src/performance/encode.ts +31 -0
- package/src/performance/index.ts +12 -0
- package/src/performance/interaction.ts +125 -0
- package/src/performance/navigation.ts +31 -0
- package/src/performance/observer.ts +112 -0
- package/src/queue.ts +33 -0
- package/test/core.test.ts +139 -0
- package/test/helper.ts +167 -0
- package/test/html/core.html +34 -0
- package/test/stub.test.ts +7 -0
- package/test/tsconfig.test.json +6 -0
- package/tsconfig.json +21 -0
- package/tslint.json +33 -0
- package/types/agent.d.ts +39 -0
- package/types/core.d.ts +150 -0
- package/types/data.d.ts +568 -0
- package/types/diagnostic.d.ts +24 -0
- package/types/global.d.ts +30 -0
- package/types/index.d.ts +40 -0
- package/types/interaction.d.ts +174 -0
- package/types/layout.d.ts +277 -0
- package/types/performance.d.ts +31 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Config } from "@clarity-types/core";
|
|
2
|
+
import { Constant } from "@clarity-types/data";
|
|
3
|
+
import configuration from "@src/core/config";
|
|
4
|
+
import * as event from "@src/core/event";
|
|
5
|
+
import * as history from "@src/core/history";
|
|
6
|
+
import * as report from "@src/core/report";
|
|
7
|
+
import * as task from "@src/core/task";
|
|
8
|
+
import * as time from "@src/core/time";
|
|
9
|
+
import * as clarity from "@src/clarity";
|
|
10
|
+
import * as custom from "@src/data/custom";
|
|
11
|
+
|
|
12
|
+
let status = false;
|
|
13
|
+
|
|
14
|
+
export function start(): void {
|
|
15
|
+
status = true;
|
|
16
|
+
time.start();
|
|
17
|
+
task.reset();
|
|
18
|
+
event.reset();
|
|
19
|
+
report.reset();
|
|
20
|
+
history.start();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stop(): void {
|
|
24
|
+
history.stop();
|
|
25
|
+
report.reset();
|
|
26
|
+
event.reset();
|
|
27
|
+
task.reset();
|
|
28
|
+
time.stop();
|
|
29
|
+
status = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function active(): boolean {
|
|
33
|
+
return status;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function check(): boolean {
|
|
37
|
+
try {
|
|
38
|
+
let globalPrivacyControlSet = navigator && "globalPrivacyControl" in navigator && navigator['globalPrivacyControl'] == true;
|
|
39
|
+
return status === false &&
|
|
40
|
+
typeof Promise !== "undefined" &&
|
|
41
|
+
window["MutationObserver"] &&
|
|
42
|
+
document["createTreeWalker"] &&
|
|
43
|
+
"now" in Date &&
|
|
44
|
+
"now" in performance &&
|
|
45
|
+
typeof WeakMap !== "undefined" &&
|
|
46
|
+
!globalPrivacyControlSet
|
|
47
|
+
} catch (ex) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function config(override: Config): boolean {
|
|
53
|
+
// Process custom configuration overrides, if available
|
|
54
|
+
if (override === null || status) { return false; }
|
|
55
|
+
for (let key in override) {
|
|
56
|
+
if (key in configuration) { configuration[key] = override[key]; }
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Suspend ends the current Clarity instance after a configured timeout period
|
|
62
|
+
// The way it differs from the "end" call is that it starts listening to
|
|
63
|
+
// user interaction events as soon as it terminates existing clarity instance.
|
|
64
|
+
// On the next interaction, it automatically starts another instance under a different page id
|
|
65
|
+
// E.g. if configured timeout is 10m, and user stays inactive for an hour.
|
|
66
|
+
// In this case, we will suspend clarity after 10m of inactivity and after another 50m when user interacts again
|
|
67
|
+
// Clarity will restart and start another instance seamlessly. Effectively not missing any active time, but also
|
|
68
|
+
// not holding the session during inactive time periods.
|
|
69
|
+
export function suspend(): void {
|
|
70
|
+
if (status) {
|
|
71
|
+
custom.event(Constant.Clarity, Constant.Suspend);
|
|
72
|
+
clarity.stop();
|
|
73
|
+
["mousemove", "touchstart"].forEach(x => event.bind(document, x, restart));
|
|
74
|
+
["resize", "scroll", "pageshow"].forEach(x => event.bind(window, x, restart));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function restart(): void {
|
|
79
|
+
clarity.start();
|
|
80
|
+
custom.event(Constant.Clarity, Constant.Restart);
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Code, Metric, Setting, Severity } from "@clarity-types/data";
|
|
2
|
+
import { report } from "@src/core/report";
|
|
3
|
+
import * as metric from "@src/data/metric";
|
|
4
|
+
import * as internal from "@src/diagnostic/internal";
|
|
5
|
+
|
|
6
|
+
// tslint:disable-next-line: ban-types
|
|
7
|
+
export default function (method: Function): Function {
|
|
8
|
+
return function (): void {
|
|
9
|
+
let start = performance.now();
|
|
10
|
+
try { method.apply(this, arguments); } catch (ex) { throw report(ex); }
|
|
11
|
+
let duration = performance.now() - start;
|
|
12
|
+
metric.sum(Metric.TotalCost, duration);
|
|
13
|
+
if (duration > Setting.LongTask) {
|
|
14
|
+
metric.count(Metric.LongTaskCount);
|
|
15
|
+
metric.max(Metric.ThreadBlockedTime, duration);
|
|
16
|
+
method.dn && internal.log(Code.FunctionExecutionTime, Severity.Info, `${method.dn}-${duration}`);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Report } from "@clarity-types/core";
|
|
2
|
+
import config from "@src/core/config";
|
|
3
|
+
import { data } from "@src/data/envelope";
|
|
4
|
+
|
|
5
|
+
let history: string[];
|
|
6
|
+
|
|
7
|
+
export function reset(): void {
|
|
8
|
+
history = [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function report(e: Error): Error {
|
|
12
|
+
console.log(`🚀 🐥 ~ report ~ e:`, e);
|
|
13
|
+
// Do not report the same message twice for the same page
|
|
14
|
+
if (history && history.indexOf(e.message) === -1) {
|
|
15
|
+
const url = config.report;
|
|
16
|
+
console.log(`🚀 🐥 ~ report ~ url:`, url);
|
|
17
|
+
if (url && url.length > 0 && data) {
|
|
18
|
+
let payload: Report = { v: data.version, p: data.projectId, u: data.userId, s: data.sessionId, n: data.pageNum };
|
|
19
|
+
if (e.message) {
|
|
20
|
+
payload.m = e.message;
|
|
21
|
+
}
|
|
22
|
+
if (e.stack) {
|
|
23
|
+
payload.e = e.stack;
|
|
24
|
+
}
|
|
25
|
+
// Using POST request instead of a GET request (img-src) to not violate existing CSP rules
|
|
26
|
+
// Since, Clarity already uses XHR to upload data, we stick with similar POST mechanism for reporting too
|
|
27
|
+
let xhr = new XMLHttpRequest();
|
|
28
|
+
console.log(`🚀 🐥 ~ report ~ xhr:`, xhr);
|
|
29
|
+
xhr.open("POST", url, true);
|
|
30
|
+
xhr.send(JSON.stringify(payload));
|
|
31
|
+
history.push(e.message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return e;
|
|
35
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Privacy } from "@clarity-types/core";
|
|
2
|
+
import * as Data from "@clarity-types/data";
|
|
3
|
+
import * as Layout from "@clarity-types/layout";
|
|
4
|
+
import config from "@src/core/config";
|
|
5
|
+
|
|
6
|
+
const catchallRegex = /\S/gi;
|
|
7
|
+
const maxUrlLength = 255;
|
|
8
|
+
let unicodeRegex = true;
|
|
9
|
+
let digitRegex = null;
|
|
10
|
+
let letterRegex = null;
|
|
11
|
+
let currencyRegex = null;
|
|
12
|
+
|
|
13
|
+
export function text(value: string, hint: string, privacy: Privacy, mangle: boolean = false, type?: string): string {
|
|
14
|
+
if (value) {
|
|
15
|
+
if (hint == "input" && (type === "checkbox" || type === "radio")) {
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
switch (privacy) {
|
|
20
|
+
case Privacy.None:
|
|
21
|
+
return value;
|
|
22
|
+
case Privacy.Sensitive:
|
|
23
|
+
switch (hint) {
|
|
24
|
+
case Layout.Constant.TextTag:
|
|
25
|
+
case "value":
|
|
26
|
+
case "placeholder":
|
|
27
|
+
case "click":
|
|
28
|
+
return redact(value);
|
|
29
|
+
case "input":
|
|
30
|
+
case "change":
|
|
31
|
+
return mangleToken(value);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
case Privacy.Text:
|
|
35
|
+
case Privacy.TextImage:
|
|
36
|
+
switch (hint) {
|
|
37
|
+
case Layout.Constant.TextTag:
|
|
38
|
+
case Layout.Constant.DataAttribute:
|
|
39
|
+
return mangle ? mangleText(value) : mask(value);
|
|
40
|
+
case "src":
|
|
41
|
+
case "srcset":
|
|
42
|
+
case "title":
|
|
43
|
+
case "alt":
|
|
44
|
+
if (privacy === Privacy.TextImage) {
|
|
45
|
+
if (hint === 'src' && value?.startsWith('blob:')) {
|
|
46
|
+
return 'blob:';
|
|
47
|
+
}
|
|
48
|
+
return Data.Constant.Empty;
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
case "value":
|
|
52
|
+
case "click":
|
|
53
|
+
case "input":
|
|
54
|
+
case "change":
|
|
55
|
+
return mangleToken(value);
|
|
56
|
+
case "placeholder":
|
|
57
|
+
return mask(value);
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
case Privacy.Exclude:
|
|
61
|
+
switch (hint) {
|
|
62
|
+
case Layout.Constant.TextTag:
|
|
63
|
+
case Layout.Constant.DataAttribute:
|
|
64
|
+
return mangle ? mangleText(value) : mask(value);
|
|
65
|
+
case "value":
|
|
66
|
+
case "input":
|
|
67
|
+
case "click":
|
|
68
|
+
case "change":
|
|
69
|
+
return Array(Data.Setting.WordLength).join(Data.Constant.Mask);
|
|
70
|
+
case "checksum":
|
|
71
|
+
return Data.Constant.Empty;
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
case Privacy.Snapshot:
|
|
75
|
+
switch (hint) {
|
|
76
|
+
case Layout.Constant.TextTag:
|
|
77
|
+
case Layout.Constant.DataAttribute:
|
|
78
|
+
return scrub(value, Data.Constant.Letter, Data.Constant.Digit);
|
|
79
|
+
case "value":
|
|
80
|
+
case "input":
|
|
81
|
+
case "click":
|
|
82
|
+
case "change":
|
|
83
|
+
return Array(Data.Setting.WordLength).join(Data.Constant.Mask);
|
|
84
|
+
case "checksum":
|
|
85
|
+
case "src":
|
|
86
|
+
case "srcset":
|
|
87
|
+
case "alt":
|
|
88
|
+
case "title":
|
|
89
|
+
return Data.Constant.Empty;
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function url(input: string, electron: boolean = false, truncate: boolean = false): string {
|
|
98
|
+
let result = input;
|
|
99
|
+
// Replace the URL for Electron apps so we don't send back file:/// URL
|
|
100
|
+
if (electron) {
|
|
101
|
+
result = `${Data.Constant.HTTPS}${Data.Constant.Electron}`;
|
|
102
|
+
} else {
|
|
103
|
+
let drop = config.drop;
|
|
104
|
+
if (drop && drop.length > 0 && input && input.indexOf("?") > 0) {
|
|
105
|
+
let [path, query] = input.split("?");
|
|
106
|
+
let swap = Data.Constant.Dropped;
|
|
107
|
+
result = path + "?" + query.split("&").map(p => drop.some(x => p.indexOf(`${x}=`) === 0) ? `${p.split("=")[0]}=${swap}` : p).join("&");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (truncate) {
|
|
112
|
+
result = result.substring(0, maxUrlLength);
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mangleText(value: string): string {
|
|
118
|
+
let trimmed = value.trim();
|
|
119
|
+
if (trimmed.length > 0) {
|
|
120
|
+
let first = trimmed[0];
|
|
121
|
+
let index = value.indexOf(first);
|
|
122
|
+
let prefix = value.substr(0, index);
|
|
123
|
+
let suffix = value.substr(index + trimmed.length);
|
|
124
|
+
return `${prefix}${trimmed.length.toString(36)}${suffix}`;
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function mask(value: string): string {
|
|
130
|
+
return value.replace(catchallRegex, Data.Constant.Mask);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function scrub(value: string, letter: string, digit: string): string {
|
|
134
|
+
regex(); // Initialize regular expressions
|
|
135
|
+
return value ? value.replace(letterRegex, letter).replace(digitRegex, digit) : value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function mangleToken(value: string): string {
|
|
139
|
+
let length = ((Math.floor(value.length / Data.Setting.WordLength) + 1) * Data.Setting.WordLength);
|
|
140
|
+
let output: string = Layout.Constant.Empty;
|
|
141
|
+
for (let i = 0; i < length; i++) {
|
|
142
|
+
output += i > 0 && i % Data.Setting.WordLength === 0 ? Data.Constant.Space : Data.Constant.Mask;
|
|
143
|
+
}
|
|
144
|
+
return output;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function regex(): void {
|
|
148
|
+
// Initialize unicode regex, if supported by the browser
|
|
149
|
+
// Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Unicode_Property_Escapes
|
|
150
|
+
if (unicodeRegex && digitRegex === null) {
|
|
151
|
+
try {
|
|
152
|
+
digitRegex = new RegExp("\\p{N}", "gu");
|
|
153
|
+
letterRegex = new RegExp("\\p{L}", "gu");
|
|
154
|
+
currencyRegex = new RegExp("\\p{Sc}", "gu");
|
|
155
|
+
} catch { unicodeRegex = false; }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function redact(value: string): string {
|
|
160
|
+
let spaceIndex = -1;
|
|
161
|
+
let gap = 0;
|
|
162
|
+
let hasDigit = false;
|
|
163
|
+
let hasEmail = false;
|
|
164
|
+
let hasWhitespace = false;
|
|
165
|
+
let array = null;
|
|
166
|
+
|
|
167
|
+
regex(); // Initialize regular expressions
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < value.length; i++) {
|
|
170
|
+
let c = value.charCodeAt(i);
|
|
171
|
+
hasDigit = hasDigit || (c >= Data.Character.Zero && c <= Data.Character.Nine); // Check for digits in the current word
|
|
172
|
+
hasEmail = hasEmail || c === Data.Character.At; // Check for @ sign anywhere within the current word
|
|
173
|
+
hasWhitespace = c === Data.Character.Tab || c === Data.Character.NewLine || c === Data.Character.Return || c === Data.Character.Blank;
|
|
174
|
+
|
|
175
|
+
// Process each word as an individual token to redact any sensitive information
|
|
176
|
+
if (i === 0 || i === value.length - 1 || hasWhitespace) {
|
|
177
|
+
// Performance optimization: Lazy load string -> array conversion only when required
|
|
178
|
+
if (hasDigit || hasEmail) {
|
|
179
|
+
if (array === null) { array = value.split(Data.Constant.Empty); }
|
|
180
|
+
// Work on a token at a time so we don't have to apply regex to a larger string
|
|
181
|
+
let token = value.substring(spaceIndex + 1, hasWhitespace ? i : i + 1);
|
|
182
|
+
// Check if unicode regex is supported, otherwise fallback to calling mask function on this token
|
|
183
|
+
if (unicodeRegex && currencyRegex !== null) {
|
|
184
|
+
// Do not redact information if the token contains a currency symbol
|
|
185
|
+
token = token.match(currencyRegex) ? token : scrub(token, Data.Constant.Letter, Data.Constant.Digit);
|
|
186
|
+
} else {
|
|
187
|
+
token = mask(token);
|
|
188
|
+
}
|
|
189
|
+
// Merge token back into array at the right place
|
|
190
|
+
array.splice(spaceIndex + 1 - gap, token.length, token);
|
|
191
|
+
gap += token.length - 1;
|
|
192
|
+
}
|
|
193
|
+
// Reset digit and email flags after every word boundary, except the beginning of string
|
|
194
|
+
if (hasWhitespace) {
|
|
195
|
+
hasDigit = false;
|
|
196
|
+
hasEmail = false;
|
|
197
|
+
spaceIndex = i;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return array ? array.join(Data.Constant.Empty) : value;
|
|
202
|
+
}
|
package/src/core/task.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { AsyncTask, Priority, RequestIdleCallbackDeadline, RequestIdleCallbackOptions, Task, Timer } from "@clarity-types/core";
|
|
2
|
+
import { TaskFunction, TaskResolve, Tasks } from "@clarity-types/core";
|
|
3
|
+
import { Code, Metric, Setting, Severity } from "@clarity-types/data";
|
|
4
|
+
import * as metadata from "@src/data/metadata";
|
|
5
|
+
import * as metric from "@src/data/metric";
|
|
6
|
+
import * as internal from "@src/diagnostic/internal";
|
|
7
|
+
|
|
8
|
+
// Track the start time to be able to compute duration at the end of the task
|
|
9
|
+
const idleTimeout = 5000;
|
|
10
|
+
let tracker: Tasks = {};
|
|
11
|
+
let queuedTasks: AsyncTask[] = [];
|
|
12
|
+
let activeTask: AsyncTask = null;
|
|
13
|
+
let pauseTask: Promise<void> = null;
|
|
14
|
+
let resumeResolve: TaskResolve = null;
|
|
15
|
+
|
|
16
|
+
export function pause(): void {
|
|
17
|
+
if (pauseTask === null) {
|
|
18
|
+
pauseTask = new Promise<void>((resolve: TaskResolve): void => {
|
|
19
|
+
resumeResolve = resolve;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resume(): void {
|
|
25
|
+
if (pauseTask) {
|
|
26
|
+
resumeResolve();
|
|
27
|
+
pauseTask = null;
|
|
28
|
+
if (activeTask === null) { run(); }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function reset(): void {
|
|
33
|
+
tracker = {};
|
|
34
|
+
queuedTasks = [];
|
|
35
|
+
activeTask = null;
|
|
36
|
+
pauseTask = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function schedule(task: TaskFunction, priority: Priority = Priority.Normal): Promise<void> {
|
|
40
|
+
// If this task is already scheduled, skip it
|
|
41
|
+
for (let q of queuedTasks) {
|
|
42
|
+
if (q.task === task) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let promise = new Promise<void>((resolve: TaskResolve): void => {
|
|
48
|
+
let insert = priority === Priority.High ? "unshift" : "push";
|
|
49
|
+
// Queue this task for asynchronous execution later
|
|
50
|
+
// We also store a unique page identifier (id) along with the task to ensure
|
|
51
|
+
// ensure that we do not accidentally execute this task in context of a different page
|
|
52
|
+
queuedTasks[insert]({ task, resolve, id: metadata.id() });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// If there is no active task running, and Clarity is not in pause state,
|
|
56
|
+
// invoke the first task in the queue synchronously. This ensures that we don't yield the thread during unload event
|
|
57
|
+
if (activeTask === null && pauseTask === null) { run(); }
|
|
58
|
+
|
|
59
|
+
return promise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function run(): void {
|
|
63
|
+
let entry = queuedTasks.shift();
|
|
64
|
+
if (entry) {
|
|
65
|
+
activeTask = entry;
|
|
66
|
+
entry.task().then((): void => {
|
|
67
|
+
// Bail out if the context in which this task was operating is different from the current page
|
|
68
|
+
// An example scenario where task could span across pages is Single Page Applications (SPA)
|
|
69
|
+
// A task that started on page #1, but completes on page #2
|
|
70
|
+
if (entry.id !== metadata.id()) { return; }
|
|
71
|
+
entry.resolve();
|
|
72
|
+
activeTask = null; // Reset active task back to null now that the promise is resolved
|
|
73
|
+
run();
|
|
74
|
+
}).catch((error: Error): void => {
|
|
75
|
+
// If one of the scheduled tasks failed, log, recover and continue processing rest of the tasks
|
|
76
|
+
if (entry.id !== metadata.id()) { return; }
|
|
77
|
+
if (error) { internal.log(Code.RunTask, Severity.Warning, error.name, error.message, error.stack); }
|
|
78
|
+
activeTask = null;
|
|
79
|
+
run();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function state(timer: Timer): Task {
|
|
85
|
+
let id = key(timer);
|
|
86
|
+
if (id in tracker) {
|
|
87
|
+
let elapsed = performance.now() - tracker[id].start;
|
|
88
|
+
return (elapsed > tracker[id].yield) ? Task.Wait : Task.Run;
|
|
89
|
+
}
|
|
90
|
+
// If this task is no longer being tracked, send stop message to the caller
|
|
91
|
+
return Task.Stop;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function start(timer: Timer): void {
|
|
95
|
+
tracker[key(timer)] = { start: performance.now(), calls: 0, yield: Setting.LongTask };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function restart(timer: Timer): void {
|
|
99
|
+
let id = key(timer);
|
|
100
|
+
if (tracker && tracker[id]) {
|
|
101
|
+
let c = tracker[id].calls;
|
|
102
|
+
let y = tracker[id].yield;
|
|
103
|
+
start(timer);
|
|
104
|
+
tracker[id].calls = c + 1;
|
|
105
|
+
tracker[id].yield = y;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function stop(timer: Timer): void {
|
|
110
|
+
let end = performance.now();
|
|
111
|
+
let id = key(timer);
|
|
112
|
+
let duration = end - tracker[id].start;
|
|
113
|
+
metric.sum(timer.cost, duration);
|
|
114
|
+
metric.count(Metric.InvokeCount);
|
|
115
|
+
|
|
116
|
+
// For the first execution, which is synchronous, time is automatically counted towards TotalDuration.
|
|
117
|
+
// However, for subsequent asynchronous runs, we need to manually update TotalDuration metric.
|
|
118
|
+
if (tracker[id].calls > 0) { metric.sum(Metric.TotalCost, duration); }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function suspend(timer: Timer): Promise<Task> {
|
|
122
|
+
// Suspend and yield the thread only if the task is still being tracked
|
|
123
|
+
// It's possible that Clarity is wrapping up instrumentation on a page and we are still in the middle of an async task.
|
|
124
|
+
// In that case, we do not wish to continue yielding thread.
|
|
125
|
+
// Instead, we will turn async task into a sync task and maximize our chances of getting some data back.
|
|
126
|
+
let id = key(timer);
|
|
127
|
+
if (id in tracker) {
|
|
128
|
+
stop(timer);
|
|
129
|
+
// some customer polyfills for requestIdleCallback return null
|
|
130
|
+
tracker[id].yield = (await wait())?.timeRemaining() || Setting.LongTask;
|
|
131
|
+
restart(timer);
|
|
132
|
+
}
|
|
133
|
+
// After we are done with suspending task, ensure that we are still operating in the right context
|
|
134
|
+
// If the task is still being tracked, continue running the task, otherwise ask caller to stop execution
|
|
135
|
+
return id in tracker ? Task.Run : Task.Stop;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function key(timer: Timer): string {
|
|
139
|
+
return `${timer.id}.${timer.cost}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function wait(): Promise<RequestIdleCallbackDeadline> {
|
|
143
|
+
if (pauseTask) { await pauseTask; }
|
|
144
|
+
return new Promise<RequestIdleCallbackDeadline>((resolve: (deadline: RequestIdleCallbackDeadline) => void): void => {
|
|
145
|
+
requestIdleCallback(resolve, { timeout: idleTimeout });
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Use native implementation of requestIdleCallback if it exists.
|
|
150
|
+
// Otherwise, fall back to a custom implementation using requestAnimationFrame & MessageChannel.
|
|
151
|
+
// While it's not possible to build a perfect polyfill given the nature of this API, the following code attempts to get close.
|
|
152
|
+
// Background context: requestAnimationFrame invokes the js code right before: style, layout and paint computation within the frame.
|
|
153
|
+
// This means, that any code that runs as part of requestAnimationFrame will by default be blocking in nature. Not what we want.
|
|
154
|
+
// For non-blocking behavior, We need to know when browser has finished painting. This can be accomplished in two different ways (hacks):
|
|
155
|
+
// (1) Use MessageChannel to pass the message, and browser will receive the message right after paint event has occured.
|
|
156
|
+
// (2) Use setTimeout call within requestAnimationFrame. This also works, but there's a risk that browser may throttle setTimeout calls.
|
|
157
|
+
// Given this information, we are currently using (1) from above. More information on (2) as well as some additional context is below:
|
|
158
|
+
// https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Performance_best_practices_for_Firefox_fe_engineers
|
|
159
|
+
function requestIdleCallbackPolyfill(callback: (deadline: RequestIdleCallbackDeadline) => void, options: RequestIdleCallbackOptions): void {
|
|
160
|
+
const startTime = performance.now();
|
|
161
|
+
const channel = new MessageChannel();
|
|
162
|
+
const incoming = channel.port1;
|
|
163
|
+
const outgoing = channel.port2;
|
|
164
|
+
incoming.onmessage = (event: MessageEvent): void => {
|
|
165
|
+
let currentTime = performance.now();
|
|
166
|
+
let elapsed = currentTime - startTime;
|
|
167
|
+
let duration = currentTime - event.data;
|
|
168
|
+
if (duration > Setting.LongTask && elapsed < options.timeout) {
|
|
169
|
+
requestAnimationFrame((): void => { outgoing.postMessage(currentTime); });
|
|
170
|
+
} else {
|
|
171
|
+
let didTimeout = elapsed > options.timeout;
|
|
172
|
+
callback({
|
|
173
|
+
didTimeout,
|
|
174
|
+
timeRemaining: (): number => didTimeout ? Setting.LongTask : Math.max(0, Setting.LongTask - duration)
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
requestAnimationFrame((): void => { outgoing.postMessage(performance.now()); });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let requestIdleCallback = window["requestIdleCallback"] || requestIdleCallbackPolyfill;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a throttled version of the provided function that only executes at most once
|
|
3
|
+
* every specified duration in milliseconds, ensuring the last event is not lost.
|
|
4
|
+
* @param func - The function to throttle.
|
|
5
|
+
* @param duration - The duration in milliseconds to wait before allowing the next execution.
|
|
6
|
+
* @returns A throttled version of the provided function with a cleanup method.
|
|
7
|
+
*/
|
|
8
|
+
export default function throttle<T extends (...args: any[]) => void>(func: T, duration: number): T & { cleanup: () => void } {
|
|
9
|
+
let lastExecutionTime = 0;
|
|
10
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
11
|
+
let lastArgs: Parameters<T> | null = null;
|
|
12
|
+
|
|
13
|
+
function throttledFunction(...args: Parameters<T>) {
|
|
14
|
+
const now = performance.now();
|
|
15
|
+
const timeSinceLastExecution = now - lastExecutionTime;
|
|
16
|
+
|
|
17
|
+
// If the function is called during the throttling period, store the arguments to ensure we don't drop the last event
|
|
18
|
+
if (lastExecutionTime !== 0 && timeSinceLastExecution < duration) {
|
|
19
|
+
lastArgs = args;
|
|
20
|
+
|
|
21
|
+
if (timeoutId) return;
|
|
22
|
+
|
|
23
|
+
timeoutId = setTimeout(() => {
|
|
24
|
+
lastExecutionTime = performance.now();
|
|
25
|
+
func.apply(this, lastArgs!);
|
|
26
|
+
lastArgs = null;
|
|
27
|
+
timeoutId = null;
|
|
28
|
+
}, duration - timeSinceLastExecution);
|
|
29
|
+
} else {
|
|
30
|
+
// Execute immediately if outside the throttling period (including the first run)
|
|
31
|
+
lastExecutionTime = now;
|
|
32
|
+
func.apply(this, args);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Add cleanup method to clear pending timeouts
|
|
37
|
+
throttledFunction.cleanup = function () {
|
|
38
|
+
if (timeoutId) {
|
|
39
|
+
clearTimeout(timeoutId);
|
|
40
|
+
timeoutId = null;
|
|
41
|
+
lastArgs = null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return throttledFunction as T & { cleanup: () => void };
|
|
46
|
+
}
|
package/src/core/time.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
let startTime = 0;
|
|
2
|
+
|
|
3
|
+
function computeStartTime(): number {
|
|
4
|
+
return performance.now() + performance.timeOrigin;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function start(): void {
|
|
8
|
+
startTime = computeStartTime();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// event.timestamp is number of milliseconds elapsed since the document was loaded
|
|
12
|
+
// since iframes can be loaded later the event timestamp is not the same as performance.now()
|
|
13
|
+
// converting everything to absolute time by adding timeorigin of the event view
|
|
14
|
+
// to synchronize times before calculating the difference with start time
|
|
15
|
+
export function time(event: UIEvent | PageTransitionEvent = null): number {
|
|
16
|
+
// If startTime is 0, Clarity hasn't been started or has been stopped
|
|
17
|
+
// Use a local baseline to maintain relative timing semantics without affecting global state
|
|
18
|
+
let baseline = startTime === 0 ? computeStartTime() : startTime;
|
|
19
|
+
let ts = event && event.timeStamp > 0 ? event.timeStamp : performance.now();
|
|
20
|
+
let origin = event && (event as UIEvent).view ? (event as UIEvent).view.performance.timeOrigin : performance.timeOrigin;
|
|
21
|
+
return Math.max(Math.round(ts + origin - baseline), 0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function stop(): void {
|
|
25
|
+
startTime = 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Event } from "@clarity-types/data";
|
|
2
|
+
import measure from "./measure";
|
|
3
|
+
|
|
4
|
+
export function setTimeout(handler: (event?: Event | boolean) => void, timeout?: number, event?: Event): number {
|
|
5
|
+
return window.setTimeout(measure(handler), timeout, event);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function clearTimeout(handle: number): void {
|
|
9
|
+
return window.clearTimeout(handle);
|
|
10
|
+
}
|