clarity-js 0.6.23

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 (87) hide show
  1. package/README.md +26 -0
  2. package/build/clarity.js +4479 -0
  3. package/build/clarity.min.js +1 -0
  4. package/build/clarity.module.js +4473 -0
  5. package/package.json +66 -0
  6. package/rollup.config.ts +38 -0
  7. package/src/clarity.ts +54 -0
  8. package/src/core/config.ts +21 -0
  9. package/src/core/copy.ts +3 -0
  10. package/src/core/event.ts +25 -0
  11. package/src/core/hash.ts +19 -0
  12. package/src/core/history.ts +69 -0
  13. package/src/core/index.ts +79 -0
  14. package/src/core/measure.ts +17 -0
  15. package/src/core/report.ts +27 -0
  16. package/src/core/scrub.ts +102 -0
  17. package/src/core/task.ts +180 -0
  18. package/src/core/time.ts +14 -0
  19. package/src/core/timeout.ts +10 -0
  20. package/src/core/version.ts +2 -0
  21. package/src/data/baseline.ts +89 -0
  22. package/src/data/compress.ts +31 -0
  23. package/src/data/custom.ts +18 -0
  24. package/src/data/dimension.ts +42 -0
  25. package/src/data/encode.ts +109 -0
  26. package/src/data/envelope.ts +46 -0
  27. package/src/data/index.ts +43 -0
  28. package/src/data/limit.ts +42 -0
  29. package/src/data/metadata.ts +232 -0
  30. package/src/data/metric.ts +51 -0
  31. package/src/data/ping.ts +36 -0
  32. package/src/data/summary.ts +34 -0
  33. package/src/data/token.ts +39 -0
  34. package/src/data/upgrade.ts +36 -0
  35. package/src/data/upload.ts +250 -0
  36. package/src/data/variable.ts +46 -0
  37. package/src/diagnostic/encode.ts +40 -0
  38. package/src/diagnostic/image.ts +23 -0
  39. package/src/diagnostic/index.ts +14 -0
  40. package/src/diagnostic/internal.ts +41 -0
  41. package/src/diagnostic/script.ts +45 -0
  42. package/src/global.ts +22 -0
  43. package/src/index.ts +8 -0
  44. package/src/interaction/click.ts +140 -0
  45. package/src/interaction/encode.ts +140 -0
  46. package/src/interaction/index.ts +45 -0
  47. package/src/interaction/input.ts +64 -0
  48. package/src/interaction/pointer.ts +108 -0
  49. package/src/interaction/resize.ts +30 -0
  50. package/src/interaction/scroll.ts +73 -0
  51. package/src/interaction/selection.ts +66 -0
  52. package/src/interaction/timeline.ts +65 -0
  53. package/src/interaction/unload.ts +25 -0
  54. package/src/interaction/visibility.ts +24 -0
  55. package/src/layout/box.ts +83 -0
  56. package/src/layout/discover.ts +27 -0
  57. package/src/layout/document.ts +46 -0
  58. package/src/layout/dom.ts +442 -0
  59. package/src/layout/encode.ts +111 -0
  60. package/src/layout/extract.ts +75 -0
  61. package/src/layout/index.ts +25 -0
  62. package/src/layout/mutation.ts +232 -0
  63. package/src/layout/node.ts +211 -0
  64. package/src/layout/offset.ts +19 -0
  65. package/src/layout/region.ts +143 -0
  66. package/src/layout/schema.ts +66 -0
  67. package/src/layout/selector.ts +24 -0
  68. package/src/layout/target.ts +44 -0
  69. package/src/layout/traverse.ts +28 -0
  70. package/src/performance/connection.ts +37 -0
  71. package/src/performance/encode.ts +40 -0
  72. package/src/performance/index.ts +15 -0
  73. package/src/performance/navigation.ts +31 -0
  74. package/src/performance/observer.ts +87 -0
  75. package/test/core.test.ts +82 -0
  76. package/test/helper.ts +104 -0
  77. package/test/html/core.html +17 -0
  78. package/test/tsconfig.test.json +6 -0
  79. package/tsconfig.json +21 -0
  80. package/tslint.json +33 -0
  81. package/types/core.d.ts +127 -0
  82. package/types/data.d.ts +344 -0
  83. package/types/diagnostic.d.ts +24 -0
  84. package/types/index.d.ts +30 -0
  85. package/types/interaction.d.ts +110 -0
  86. package/types/layout.d.ts +200 -0
  87. package/types/performance.d.ts +40 -0
@@ -0,0 +1,232 @@
1
+ import { Time } from "@clarity-types/core";
2
+ import { BooleanFlag, Constant, Dimension, Metadata, MetadataCallback, Metric, Session, User, Setting } from "@clarity-types/data";
3
+ import * as core from "@src/core";
4
+ import config from "@src/core/config";
5
+ import hash from "@src/core/hash";
6
+ import * as dimension from "@src/data/dimension";
7
+ import * as metric from "@src/data/metric";
8
+ import { set } from "@src/data/variable";
9
+
10
+ export let data: Metadata = null;
11
+ export let callback: MetadataCallback = null;
12
+ let rootDomain = null;
13
+
14
+ export function start(): void {
15
+ callback = null;
16
+ rootDomain = null;
17
+ const ua = navigator && "userAgent" in navigator ? navigator.userAgent : Constant.Empty;
18
+ const title = document && document.title ? document.title : Constant.Empty;
19
+
20
+ // Populate ids for this page
21
+ let s = session();
22
+ let u = user();
23
+ data = {
24
+ projectId: config.projectId || hash(location.host),
25
+ userId: u.id,
26
+ sessionId: s.session,
27
+ pageNum: s.count
28
+ }
29
+
30
+ // Override configuration based on what's in the session storage
31
+ config.lean = config.track && s.upgrade !== null ? s.upgrade === BooleanFlag.False : config.lean;
32
+ config.upload = config.track && typeof config.upload === Constant.String && s.upload ? s.upload : config.upload;
33
+ // Log dimensions
34
+ dimension.log(Dimension.UserAgent, ua);
35
+ dimension.log(Dimension.PageTitle, title);
36
+ dimension.log(Dimension.Url, location.href);
37
+ dimension.log(Dimension.Referrer, document.referrer);
38
+ dimension.log(Dimension.TabId, tab());
39
+ dimension.log(Dimension.PageLanguage, document.documentElement.lang);
40
+ dimension.log(Dimension.DocumentDirection, document.dir);
41
+
42
+ if (navigator) {
43
+ dimension.log(Dimension.Language, (<any>navigator).userLanguage || navigator.language);
44
+ }
45
+
46
+ // Metrics
47
+ metric.max(Metric.ClientTimestamp, s.ts);
48
+ metric.max(Metric.Playback, BooleanFlag.False);
49
+ if (screen) {
50
+ metric.max(Metric.ScreenWidth, Math.round(screen.width));
51
+ metric.max(Metric.ScreenHeight, Math.round(screen.height));
52
+ metric.max(Metric.ColorDepth, Math.round(screen.colorDepth));
53
+ }
54
+
55
+ // Read cookies specified in configuration
56
+ for (let key of config.cookies) {
57
+ let value = getCookie(key);
58
+ if (value) { set(key, value); }
59
+ }
60
+
61
+ // Track ids using a cookie if configuration allows it
62
+ track(u);
63
+ }
64
+
65
+ export function stop(): void {
66
+ callback = null;
67
+ rootDomain = null;
68
+ }
69
+
70
+ export function metadata(cb: MetadataCallback): void {
71
+ callback = cb;
72
+ }
73
+
74
+ export function id(): string {
75
+ return data ? [data.userId, data.sessionId, data.pageNum].join(Constant.Dot) : Constant.Empty;
76
+ }
77
+
78
+ export function consent(): void {
79
+ if (core.active()) {
80
+ config.track = true;
81
+ track(user(), BooleanFlag.True);
82
+ }
83
+ }
84
+
85
+ export function clear(): void {
86
+ // Clear any stored information in the cookie that tracks session information so we can restart fresh the next time
87
+ setCookie(Constant.SessionKey, Constant.Empty, 0);
88
+ }
89
+
90
+ function tab(): string {
91
+ let id = shortid();
92
+ if (config.track && supported(window, Constant.SessionStorage)) {
93
+ let value = sessionStorage.getItem(Constant.TabKey);
94
+ id = value ? value : id;
95
+ sessionStorage.setItem(Constant.TabKey, id);
96
+ }
97
+ return id;
98
+ }
99
+
100
+ export function save(): void {
101
+ let ts = Math.round(Date.now());
102
+ let upgrade = config.lean ? BooleanFlag.False : BooleanFlag.True;
103
+ let upload = config.upload && typeof config.upload === Constant.String ? (config.upload as string).replace(Constant.HTTPS, Constant.Empty) : Constant.Empty;
104
+ if (upgrade && callback) { callback(data, !config.lean); }
105
+ setCookie(Constant.SessionKey, [data.sessionId, ts, data.pageNum, upgrade, upload].join(Constant.Pipe), Setting.SessionExpire);
106
+ }
107
+
108
+ function supported(target: Window | Document, api: string): boolean {
109
+ try { return !!target[api]; } catch { return false; }
110
+ }
111
+
112
+ function track(u: User, consent: BooleanFlag = null): void {
113
+ // If consent is not explicitly specified, infer it from the user object
114
+ consent = consent === null ? u.consent : consent;
115
+ // Convert time precision into days to reduce number of bytes we have to write in a cookie
116
+ // E.g. Math.ceil(1628735962643 / (24*60*60*1000)) => 18852 (days) => ejo in base36 (13 bytes => 3 bytes)
117
+ let end = Math.ceil((Date.now() + (Setting.Expire * Time.Day))/Time.Day);
118
+ // To avoid cookie churn, write user id cookie only once every day
119
+ if (u.expiry === null || Math.abs(end - u.expiry) >= Setting.CookieInterval || u.consent !== consent) {
120
+ setCookie(Constant.CookieKey, [data.userId, Setting.CookieVersion, end.toString(36), consent].join(Constant.Pipe), Setting.Expire);
121
+ }
122
+ }
123
+
124
+ function shortid(): string {
125
+ let id = Math.floor(Math.random() * Math.pow(2, 32));
126
+ if (window && window.crypto && window.crypto.getRandomValues && Uint32Array) {
127
+ id = window.crypto.getRandomValues(new Uint32Array(1))[0];
128
+ }
129
+ return id.toString(36);
130
+ }
131
+
132
+ function session(): Session {
133
+ let output: Session = { session: shortid(), ts: Math.round(Date.now()), count: 1, upgrade: null, upload: Constant.Empty };
134
+ let value = getCookie(Constant.SessionKey);
135
+ if (value) {
136
+ let parts = value.split(Constant.Pipe);
137
+ // Making it backward & forward compatible by using greater than comparison (v0.6.21)
138
+ // In future version, we can reduce the parts length to be 5 where the last part contains the full upload URL
139
+ if (parts.length >= 5 && output.ts - num(parts[1]) < Setting.SessionTimeout) {
140
+ output.session = parts[0];
141
+ output.count = num(parts[2]) + 1;
142
+ output.upgrade = num(parts[3]);
143
+ output.upload = parts.length >= 6 ? `${Constant.HTTPS}${parts[5]}/${parts[4]}` : `${Constant.HTTPS}${parts[4]}`;
144
+ }
145
+ }
146
+ return output;
147
+ }
148
+
149
+ function num(string: string, base: number = 10): number {
150
+ return parseInt(string, base);
151
+ }
152
+
153
+ function user(): User {
154
+ let output: User = { id: shortid(), expiry: null, consent: BooleanFlag.False };
155
+ let cookie = getCookie(Constant.CookieKey);
156
+ if(cookie && cookie.length > 0) {
157
+ // Splitting and looking up first part for forward compatibility, in case we wish to store additional information in a cookie
158
+ let parts = cookie.split(Constant.Pipe);
159
+ // For backward compatibility introduced in v0.6.18; following code can be removed with future iterations
160
+ // Count number of times Clarity's user cookie crumb appears in document.cookie (could be on different sub-domains e.g. www.domain.com and .domain.com)
161
+ let count = 0;
162
+ for (let c of document.cookie.split(Constant.Semicolon)) { count += c.split(Constant.Equals)[0].trim() === Constant.CookieKey ? 1 : 0; }
163
+ // Check if we either got version-less cookie value or saw multiple copies of the user cookie crumbs
164
+ // In both these cases, we go ahead and delete the existing cookie set on current domain
165
+ if (parts.length === 1 || count > 1) {
166
+ let deleted = `${Constant.Semicolon}${Constant.Expires}${(new Date(0)).toUTCString()}${Constant.Path}`;
167
+ // First, delete current user cookie which might be set on current sub-domain vs. root domain
168
+ document.cookie = `${Constant.CookieKey}=${deleted}`;
169
+ // Second, same thing for current session cookie so it can be re-written later with the root domain
170
+ document.cookie = `${Constant.SessionKey}=${deleted}`;
171
+ }
172
+ // End code for backward compatibility
173
+ // Read version information and timestamp from cookie, if available
174
+ if (parts.length > 2) { output.expiry = num(parts[2], 36); }
175
+ // Check if we have explicit consent to track this user
176
+ if (parts.length > 3 && num(parts[3]) === 1) { output.consent = BooleanFlag.True; }
177
+ // Set track configuration to true for this user if we have explicit consent, regardless of project setting
178
+ config.track = config.track || output.consent === BooleanFlag.True;
179
+ // Get user id from cookie only if we tracking is enabled, otherwise fallback to a random id
180
+ output.id = config.track ? parts[0] : output.id;
181
+ }
182
+ return output;
183
+ }
184
+
185
+ function getCookie(key: string): string {
186
+ if (supported(document, Constant.Cookie)) {
187
+ let cookies: string[] = document.cookie.split(Constant.Semicolon);
188
+ if (cookies) {
189
+ for (let i = 0; i < cookies.length; i++) {
190
+ let pair: string[] = cookies[i].split(Constant.Equals);
191
+ if (pair.length > 1 && pair[0] && pair[0].trim() === key) {
192
+ return pair[1];
193
+ }
194
+ }
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function setCookie(key: string, value: string, time: number): void {
201
+ if (config.track && ((navigator && navigator.cookieEnabled) || supported(document, Constant.Cookie))) {
202
+ let expiry = new Date();
203
+ expiry.setDate(expiry.getDate() + time);
204
+ let expires = expiry ? Constant.Expires + expiry.toUTCString() : Constant.Empty;
205
+ let cookie = `${key}=${value}${Constant.Semicolon}${expires}${Constant.Path}`;
206
+ try {
207
+ // Attempt to get the root domain only once and fall back to writing cookie on the current domain.
208
+ if (rootDomain === null) {
209
+ let hostname = location.hostname ? location.hostname.split(Constant.Dot) : [];
210
+ // Walk backwards on a domain and attempt to set a cookie, until successful
211
+ for (let i = hostname.length - 1; i >= 0; i--) {
212
+ rootDomain = `.${hostname[i]}${rootDomain ? rootDomain : Constant.Empty}`;
213
+ // We do not wish to attempt writing a cookie on the absolute last part of the domain, e.g. .com or .net.
214
+ // So we start attempting after second-last part, e.g. .domain.com (PASS) or .co.uk (FAIL)
215
+ if (i < hostname.length - 1) {
216
+ // Write the cookie on the current computed top level domain
217
+ document.cookie = `${cookie}${Constant.Semicolon}${Constant.Domain}${rootDomain}`;
218
+ // Once written, check if the cookie exists and its value matches exactly with what we intended to set
219
+ // Checking for exact value match helps us eliminate a corner case where the cookie may already be present with a different value
220
+ // If the check is successful, no more action is required and we can return from the function since rootDomain cookie is already set
221
+ // If the check fails, continue with the for loop until we can successfully set and verify the cookie
222
+ if (getCookie(key) === value) { return; }
223
+ }
224
+ }
225
+ // Finally, if we were not successful and gone through all the options, play it safe and reset rootDomain to be empty
226
+ // This forces our code to fall back to always writing cookie to the current domain
227
+ rootDomain = Constant.Empty;
228
+ }
229
+ } catch { rootDomain = Constant.Empty; }
230
+ document.cookie = rootDomain ? `${cookie}${Constant.Semicolon}${Constant.Domain}${rootDomain}` : cookie;
231
+ }
232
+ }
@@ -0,0 +1,51 @@
1
+ import { Event, Metric, MetricData } from "@clarity-types/data";
2
+ import encode from "./encode";
3
+
4
+ export let data: MetricData = null;
5
+ export let updates: MetricData = null;
6
+
7
+ export function start(): void {
8
+ data = {};
9
+ updates = {};
10
+ count(Metric.InvokeCount);
11
+ }
12
+
13
+ export function stop(): void {
14
+ data = {};
15
+ updates = {};
16
+ }
17
+
18
+ export function count(metric: Metric, increment: number = 1): void {
19
+ if (!(metric in data)) { data[metric] = 0; }
20
+ if (!(metric in updates)) { updates[metric] = 0; }
21
+ data[metric] += increment;
22
+ updates[metric] += increment;
23
+ }
24
+
25
+ export function sum(metric: Metric, value: number): void {
26
+ if (value !== null) {
27
+ if (!(metric in data)) { data[metric] = 0; }
28
+ if (!(metric in updates)) { updates[metric] = 0; }
29
+ data[metric] += value;
30
+ updates[metric] += value;
31
+ }
32
+ }
33
+
34
+ export function max(metric: Metric, value: number): void {
35
+ // Ensure that we do not process null or NaN values
36
+ if (value !== null && isNaN(value) === false) {
37
+ if (!(metric in data)) { data[metric] = 0; }
38
+ if (value > data[metric] || data[metric] === 0) {
39
+ updates[metric] = value;
40
+ data[metric] = value;
41
+ }
42
+ }
43
+ }
44
+
45
+ export function compute(): void {
46
+ encode(Event.Metric);
47
+ }
48
+
49
+ export function reset(): void {
50
+ updates = {};
51
+ }
@@ -0,0 +1,36 @@
1
+ import { Event, PingData, Setting } from "@clarity-types/data";
2
+ import { suspend } from "@src/core";
3
+ import { time } from "@src/core/time";
4
+ import { clearTimeout, setTimeout } from "@src/core/timeout";
5
+ import encode from "./encode";
6
+
7
+ export let data: PingData;
8
+ let last = 0;
9
+ let interval = 0;
10
+ let timeout: number = null;
11
+
12
+ export function start(): void {
13
+ interval = Setting.PingInterval;
14
+ last = 0;
15
+ }
16
+
17
+ export function reset(): void {
18
+ if (timeout) { clearTimeout(timeout); }
19
+ timeout = setTimeout(ping, interval);
20
+ last = time();
21
+ }
22
+
23
+ function ping(): void {
24
+ let now = time();
25
+ data = { gap: now - last };
26
+ encode(Event.Ping);
27
+ if (data.gap < Setting.PingTimeout) {
28
+ timeout = setTimeout(ping, interval);
29
+ } else { suspend(); }
30
+ }
31
+
32
+ export function stop(): void {
33
+ clearTimeout(timeout);
34
+ last = 0;
35
+ interval = 0;
36
+ }
@@ -0,0 +1,34 @@
1
+ import { Event, SummaryData, Setting } from "@clarity-types/data";
2
+ import encode from "./encode";
3
+
4
+ export let data: SummaryData = null;
5
+
6
+ export function start(): void {
7
+ data = {};
8
+ }
9
+
10
+ export function stop(): void {
11
+ data = {};
12
+ }
13
+
14
+ export function track(event: Event, time: number): void {
15
+ if (!(event in data)) {
16
+ data[event] = [[time, 0]];
17
+ } else {
18
+ let e = data[event];
19
+ let last = e[e.length - 1];
20
+ // Add a new entry only if the new event occurs after configured interval
21
+ // Otherwise, extend the duration of the previous entry
22
+ if (time - last[0] > Setting.SummaryInterval) {
23
+ data[event].push([time, 0]);
24
+ } else { last[1] = time - last[0]; }
25
+ }
26
+ }
27
+
28
+ export function compute(): void {
29
+ encode(Event.Summary);
30
+ }
31
+
32
+ export function reset(): void {
33
+ data = {};
34
+ }
@@ -0,0 +1,39 @@
1
+ import {Constant, Token} from "@clarity-types/data";
2
+
3
+ // Following code takes an array of tokens and transforms it to optimize for repeating tokens and make it efficient to send over the wire
4
+ // The way it works is that it iterate over all tokens and checks if the current token was already seen in the tokens array so far
5
+ // If so, it replaces the token with its reference (index). This helps us save bytes by not repeating the same value twice.
6
+ // E.g. If tokens array is: ["hello", "world", "coding", "language", "world", "language", "example"]
7
+ // Then the resulting tokens array after following code execution would be: ["hello", "world", "coding", "language", [1, 3], "example"]
8
+ // Where [1,3] points to tokens[1] => "world" and tokens[3] => "language"
9
+ export default function(tokens: Token[]): Token[] {
10
+ let output: Token[] = [];
11
+ let lookup: {[key: string]: number} = {};
12
+ let pointer = 0;
13
+ let reference = null;
14
+ for (let i = 0; i < tokens.length; i++) {
15
+ // Only optimize for string values
16
+ if (typeof tokens[i] === Constant.String) {
17
+ let token = tokens[i] as string;
18
+ let index = lookup[token] || -1;
19
+ if (index >= 0) {
20
+ if (reference) { reference.push(index); } else {
21
+ reference = [index];
22
+ output.push(reference);
23
+ pointer++;
24
+ }
25
+ } else {
26
+ reference = null;
27
+ output.push(token);
28
+ lookup[token] = pointer++;
29
+ }
30
+ } else {
31
+ // If the value is anything other than string, append it as it is to the output array
32
+ // And, also increment the pointer to stay in sync with output array
33
+ reference = null;
34
+ output.push(tokens[i]);
35
+ pointer++;
36
+ }
37
+ }
38
+ return output;
39
+ }
@@ -0,0 +1,36 @@
1
+ import { Constant, Event, UpgradeData } from "@clarity-types/data";
2
+ import * as core from "@src/core";
3
+ import config from "@src/core/config";
4
+ import encode from "@src/data/encode";
5
+ import * as metadata from "@src/data/metadata";
6
+
7
+ export let data: UpgradeData = null;
8
+
9
+ export function start(): void {
10
+ if (!config.lean && config.upgrade) { config.upgrade(Constant.Config); }
11
+ data = null;
12
+ }
13
+
14
+ // Following call will upgrade the session from lean mode into the full mode retroactively from the start of the page.
15
+ // As part of the lean mode, we do not send back any layout information - including discovery of DOM and mutations.
16
+ // However, if there's a need for full fidelity playback, calling this function will disable lean mode
17
+ // and send all backed up layout events to the server.
18
+ export function upgrade(key: string): void {
19
+ // Upgrade only if Clarity was successfully activated on the page
20
+ if (core.active() && config.lean) {
21
+ config.lean = false;
22
+ data = { key };
23
+
24
+ // Update metadata to track we have upgraded this session
25
+ metadata.save();
26
+
27
+ // Callback upgrade handler, if configured
28
+ if (config.upgrade) { config.upgrade(key); }
29
+
30
+ encode(Event.Upgrade);
31
+ }
32
+ }
33
+
34
+ export function stop(): void {
35
+ data = null;
36
+ }
@@ -0,0 +1,250 @@
1
+ import { UploadCallback } from "@clarity-types/core";
2
+ import { BooleanFlag, Check, Constant, EncodedPayload, Event, Metric, Setting, Token, Transit, UploadData, XMLReadyState } from "@clarity-types/data";
3
+ import * as clarity from "@src/clarity";
4
+ import config from "@src/core/config";
5
+ import measure from "@src/core/measure";
6
+ import { time } from "@src/core/time";
7
+ import { clearTimeout, setTimeout } from "@src/core/timeout";
8
+ import compress from "@src/data/compress";
9
+ import encode from "@src/data/encode";
10
+ import * as envelope from "@src/data/envelope";
11
+ import * as data from "@src/data/index";
12
+ import * as limit from "@src/data/limit";
13
+ import * as metadata from "@src/data/metadata";
14
+ import * as metric from "@src/data/metric";
15
+ import * as ping from "@src/data/ping";
16
+ import * as timeline from "@src/interaction/timeline";
17
+ import * as region from "@src/layout/region";
18
+
19
+ let discoverBytes: number = 0;
20
+ let playbackBytes: number = 0;
21
+ let playback: string[];
22
+ let analysis: string[];
23
+ let timeout: number = null;
24
+ let transit: Transit;
25
+ let active: boolean;
26
+ let queuedTime: number = 0;
27
+ export let track: UploadData;
28
+
29
+ export function start(): void {
30
+ active = true;
31
+ discoverBytes = 0;
32
+ playbackBytes = 0;
33
+ queuedTime = 0;
34
+ playback = [];
35
+ analysis = [];
36
+ transit = {};
37
+ track = null;
38
+ }
39
+
40
+ export function queue(tokens: Token[], transmit: boolean = true): void {
41
+ if (active) {
42
+ let now = time();
43
+ let type = tokens.length > 1 ? tokens[1] : null;
44
+ let event = JSON.stringify(tokens);
45
+
46
+ switch (type) {
47
+ case Event.Discover:
48
+ discoverBytes += event.length;
49
+ case Event.Box:
50
+ case Event.Mutation:
51
+ playbackBytes += event.length;
52
+ playback.push(event);
53
+ break;
54
+ default:
55
+ analysis.push(event);
56
+ break;
57
+ }
58
+
59
+ // Following two checks are precautionary and act as a fail safe mechanism to get out of unexpected situations.
60
+ // Check 1: If for any reason the upload hasn't happened after waiting for 2x the config.delay time,
61
+ // reset the timer. This allows Clarity to attempt an upload again.
62
+ let gap = delay();
63
+ if (now - queuedTime > (gap * 2)) {
64
+ clearTimeout(timeout);
65
+ timeout = null;
66
+ }
67
+
68
+ // Transmit Check: When transmit is set to true (default), it indicates that we should schedule an upload
69
+ // However, in certain scenarios - like metric calculation - which are triggered as part of an existing upload
70
+ // We enrich the data going out with the existing upload. In these cases, call to upload comes with 'transmit' set to false.
71
+ if (transmit && timeout === null) {
72
+ if (type !== Event.Ping) { ping.reset(); }
73
+ timeout = setTimeout(upload, gap);
74
+ queuedTime = now;
75
+ limit.check(playbackBytes);
76
+ }
77
+ }
78
+ }
79
+
80
+ export function stop(): void {
81
+ clearTimeout(timeout);
82
+ upload(true);
83
+ discoverBytes = 0;
84
+ playbackBytes = 0;
85
+ queuedTime = 0;
86
+ playback = [];
87
+ analysis = [];
88
+ transit = {};
89
+ track = null;
90
+ active = false;
91
+ }
92
+
93
+ async function upload(final: boolean = false): Promise<void> {
94
+ timeout = null;
95
+
96
+ // Check if we can send playback bytes over the wire or not
97
+ // For better instrumentation coverage, we send playback bytes from second sequence onwards
98
+ // And, we only send playback metric when we are able to send the playback bytes back to server
99
+ let sendPlaybackBytes = config.lean === false && playbackBytes > 0 && (playbackBytes < Setting.MaxFirstPayloadBytes || envelope.data.sequence > 0);
100
+ if (sendPlaybackBytes) { metric.max(Metric.Playback, BooleanFlag.True); }
101
+
102
+ // CAUTION: Ensure "transmit" is set to false in the queue function for following events
103
+ // Otherwise you run a risk of infinite loop.
104
+ region.compute();
105
+ timeline.compute();
106
+ data.compute();
107
+
108
+ // Treat this as the last payload only if final boolean was explicitly set to true.
109
+ // In real world tests, we noticed that certain third party scripts (e.g. https://www.npmjs.com/package/raven-js)
110
+ // could inject function arguments for internal tracking (likely stack traces for script errors).
111
+ // For these edge cases, we want to ensure that an injected object (e.g. {"key": "value"}) isn't mistaken to be true.
112
+ let last = final === true;
113
+ let e = JSON.stringify(envelope.envelope(last));
114
+ let a = `[${analysis.join()}]`;
115
+
116
+ let p = sendPlaybackBytes ? `[${playback.join()}]` : Constant.Empty;
117
+ let encoded: EncodedPayload = {e, a, p};
118
+
119
+ // Get the payload ready for sending over the wire
120
+ // We also attempt to compress the payload if it is not the last payload and the browser supports it
121
+ // In all other cases, we continue to send back string value
122
+ let payload = stringify(encoded);
123
+ let zipped = last ? null : await compress(payload)
124
+ metric.sum(Metric.TotalBytes, zipped ? zipped.length : payload.length);
125
+ send(payload, zipped, envelope.data.sequence, last);
126
+
127
+ // Clear out events now that payload has been dispatched
128
+ analysis = [];
129
+ if (sendPlaybackBytes) {
130
+ playback = [];
131
+ playbackBytes = 0;
132
+ discoverBytes = 0;
133
+ }
134
+ }
135
+
136
+ function stringify(encoded: EncodedPayload): string {
137
+ return encoded.p.length > 0 ? `{"e":${encoded.e},"a":${encoded.a},"p":${encoded.p}}` : `{"e":${encoded.e},"a":${encoded.a}}`;
138
+ }
139
+
140
+ function send(payload: string, zipped: Uint8Array, sequence: number, beacon: boolean = false): void {
141
+ // Upload data if a valid URL is defined in the config
142
+ if (typeof config.upload === Constant.String) {
143
+ const url = config.upload as string;
144
+ let dispatched = false;
145
+
146
+ // If it's the last payload, attempt to upload using sendBeacon first.
147
+ // The advantage to using sendBeacon is that browser can decide to upload asynchronously, improving chances of success
148
+ // However, we don't want to rely on it for every payload, since we have no ability to retry if the upload failed.
149
+ // Also, in case of sendBeacon, we do not have a way to alter HTTP headers and therefore can't send compressed payload
150
+ if (beacon && "sendBeacon" in navigator) {
151
+ dispatched = navigator.sendBeacon(url, payload);
152
+ if (dispatched) { done(sequence); }
153
+ }
154
+
155
+ // Before initiating XHR upload, we check if the data has already been uploaded using sendBeacon
156
+ // There are two cases when dispatched could still be false:
157
+ // a) It's not the last payload, and therefore we didn't attempt sending sendBeacon
158
+ // b) It's the last payload, however, we failed to queue sendBeacon call and need to now fall back to XHR.
159
+ // E.g. if data is over 64KB, several user agents (like Chrome) will reject to queue the sendBeacon call.
160
+ if (dispatched === false) {
161
+ // While tracking payload for retry, we only track string value of the payload to err on the safe side
162
+ // Not all browsers support compression API and the support for it in supported browsers is still experimental
163
+ if (sequence in transit) { transit[sequence].attempts++; } else { transit[sequence] = { data: payload, attempts: 1 }; }
164
+ let xhr = new XMLHttpRequest();
165
+ xhr.open("POST", url);
166
+ if (sequence !== null) { xhr.onreadystatechange = (): void => { measure(check)(xhr, sequence); }; }
167
+ xhr.withCredentials = true;
168
+ if (zipped) {
169
+ // If we do have valid compressed array, send it with appropriate HTTP headers so server can decode it appropriately
170
+ xhr.setRequestHeader(Constant.Accept, Constant.ClarityGzip);
171
+ xhr.send(zipped);
172
+ } else {
173
+ // In all other cases, continue sending string back to the server
174
+ xhr.send(payload);
175
+ }
176
+ }
177
+ } else if (config.upload) {
178
+ const callback = config.upload as UploadCallback;
179
+ callback(payload);
180
+ done(sequence);
181
+ }
182
+ }
183
+
184
+ function check(xhr: XMLHttpRequest, sequence: number): void {
185
+ var transitData = transit[sequence];
186
+ if (xhr && xhr.readyState === XMLReadyState.Done && transitData) {
187
+ // Attempt send payload again (as configured in settings) if we do not receive a success (2XX) response code back from the server
188
+ if ((xhr.status < 200 || xhr.status > 208) && transitData.attempts <= Setting.RetryLimit) {
189
+ // We re-attempt in all cases except when server explicitly rejects our request with 4XX error
190
+ if (xhr.status >= 400 && xhr.status < 500) {
191
+ // In case of a 4XX response from the server, we bail out instead of trying again
192
+ limit.trigger(Check.Server);
193
+ } else {
194
+ // Browser will send status = 0 when it refuses to put network request over the wire
195
+ // This could happen for several reasons, couple of known ones are:
196
+ // 1: Browsers block upload because of content security policy violation
197
+ // 2: Safari will terminate pending XHR requests with status code 0 if the user navigates away from the page
198
+ // In any case, we switch the upload URL to fallback configuration (if available) before re-trying one more time
199
+ if (xhr.status === 0) { config.upload = config.fallback ? config.fallback : config.upload; }
200
+ // In all other cases, re-attempt sending the same data
201
+ // For retry we always fallback to string payload, even though we may have attempted
202
+ // sending zipped payload earlier
203
+ send(transitData.data, null, sequence);
204
+ }
205
+ } else {
206
+ track = { sequence, attempts: transitData.attempts, status: xhr.status };
207
+ // Send back an event only if we were not successful in our first attempt
208
+ if (transitData.attempts > 1) { encode(Event.Upload); }
209
+ // Handle response if it was a 200 response with a valid body
210
+ if (xhr.status === 200 && xhr.responseText) { response(xhr.responseText); }
211
+ // If we exhausted our retries then trigger Clarity's shutdown for this page since the data will be incomplete
212
+ if (xhr.status === 0) {
213
+ // And, right before we terminate the session, we will attempt one last time to see if we can use
214
+ // different transport option (sendBeacon vs. XHR) to get this data to the server for analysis purposes
215
+ send(transitData.data, null, sequence, true);
216
+ limit.trigger(Check.Retry);
217
+ }
218
+ // Signal that this request completed successfully
219
+ if (xhr.status >= 200 && xhr.status <= 208) { done(sequence); }
220
+ // Stop tracking this payload now that it's all done
221
+ delete transit[sequence];
222
+ }
223
+ }
224
+ }
225
+
226
+ function done(sequence: number): void {
227
+ // If we everything went successfully, and it is the first sequence, save this session for future reference
228
+ if (sequence === 1) { metadata.save(); }
229
+ }
230
+
231
+ function delay(): number {
232
+ // Progressively increase delay as we continue to send more payloads from the client to the server
233
+ // If we are not uploading data to a server, and instead invoking UploadCallback, in that case keep returning configured value
234
+ let gap = config.lean === false && discoverBytes > 0 ? Setting.MinUploadDelay : envelope.data.sequence * config.delay;
235
+ return typeof config.upload === Constant.String ? Math.max(Math.min(gap, Setting.MaxUploadDelay), Setting.MinUploadDelay) : config.delay;
236
+ }
237
+
238
+ function response(payload: string): void {
239
+ let key = payload && payload.length > 0 ? payload.split(" ")[0] : Constant.Empty;
240
+ switch (key) {
241
+ case Constant.End:
242
+ // Clear out session storage and end the session so we can start fresh the next time
243
+ limit.trigger(Check.Server);
244
+ break;
245
+ case Constant.Upgrade:
246
+ // Upgrade current session to send back playback information
247
+ clarity.upgrade(Constant.Auto);
248
+ break;
249
+ }
250
+ }