clarity-js 0.6.27 → 0.6.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clarity-js",
3
- "version": "0.6.27",
3
+ "version": "0.6.31",
4
4
  "description": "An analytics library that uses web page interactions to generate aggregated insights",
5
5
  "author": "Microsoft Corp.",
6
6
  "license": "MIT",
@@ -1,12 +1,13 @@
1
1
  import { Setting } from "@clarity-types/core";
2
2
  import { Metric } from "@clarity-types/data";
3
+ import { report } from "@src/core/report";
3
4
  import * as metric from "@src/data/metric";
4
5
 
5
6
  // tslint:disable-next-line: ban-types
6
7
  export default function (method: Function): Function {
7
8
  return function (): void {
8
9
  let start = performance.now();
9
- method.apply(this, arguments);
10
+ try { method.apply(this, arguments); } catch (ex) { throw report(ex); }
10
11
  let duration = performance.now() - start;
11
12
  metric.sum(Metric.TotalCost, duration);
12
13
  if (duration > Setting.LongTask) {
@@ -1,7 +1,6 @@
1
1
  import { Report } from "@clarity-types/core";
2
- import { Check } from "@clarity-types/data";
3
2
  import config from "@src/core/config";
4
- import { data } from "@src/data/metadata";
3
+ import { data } from "@src/data/envelope";
5
4
 
6
5
  let history: string[];
7
6
 
@@ -9,19 +8,21 @@ export function reset(): void {
9
8
  history = [];
10
9
  }
11
10
 
12
- export function report(check: Check, message: string = null): void {
11
+ export function report(e: Error): Error {
13
12
  // Do not report the same message twice for the same page
14
- if (history && history.indexOf(message) === -1) {
13
+ if (history && history.indexOf(e.message) === -1) {
15
14
  const url = config.report;
16
15
  if (url && url.length > 0) {
17
- let payload: Report = {c: check, p: data.projectId, u: data.userId, s: data.sessionId, n: data.pageNum };
18
- if (message) payload.m = message;
16
+ let payload: Report = {v: data.version, p: data.projectId, u: data.userId, s: data.sessionId, n: data.pageNum};
17
+ if (e.message) { payload.m = e.message; }
18
+ if (e.stack) { payload.e = e.stack; }
19
19
  // Using POST request instead of a GET request (img-src) to not violate existing CSP rules
20
20
  // Since, Clarity already uses XHR to upload data, we stick with similar POST mechanism for reporting too
21
21
  let xhr = new XMLHttpRequest();
22
22
  xhr.open("POST", url);
23
23
  xhr.send(JSON.stringify(payload));
24
- history.push(message);
24
+ history.push(e.message);
25
25
  }
26
26
  }
27
+ return e;
27
28
  }
@@ -1,2 +1,2 @@
1
- let version = "0.6.27";
1
+ let version = "0.6.31";
2
2
  export default version;
package/src/data/limit.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Check, Event, LimitData, Setting } from "@clarity-types/data";
2
2
  import * as clarity from "@src/clarity";
3
- import { report } from "@src/core/report";
4
3
  import { time } from "@src/core/time";
5
4
  import * as envelope from "@src/data/envelope";
6
5
  import * as metadata from "@src/data/metadata";
@@ -25,7 +24,6 @@ export function check(bytes: number): void {
25
24
  }
26
25
 
27
26
  export function trigger(reason: Check): void {
28
- report(reason);
29
27
  data.check = reason;
30
28
  metadata.clear();
31
29
  clarity.stop();
@@ -38,10 +38,10 @@ export function start(): void {
38
38
  dimension.log(Dimension.TabId, tab());
39
39
  dimension.log(Dimension.PageLanguage, document.documentElement.lang);
40
40
  dimension.log(Dimension.DocumentDirection, document.dir);
41
-
42
41
  if (navigator) {
43
42
  dimension.log(Dimension.Language, (<any>navigator).userLanguage || navigator.language);
44
43
  metric.max(Metric.Automation, navigator.webdriver ? BooleanFlag.True : BooleanFlag.False);
44
+ userAgentData();
45
45
  }
46
46
 
47
47
  // Metrics
@@ -64,13 +64,39 @@ export function start(): void {
64
64
  track(u);
65
65
  }
66
66
 
67
+ export function userAgentData(): void {
68
+ if (navigator["userAgentData"] && navigator["userAgentData"].getHighEntropyValues) {
69
+ navigator["userAgentData"].getHighEntropyValues(
70
+ ["model",
71
+ "platform",
72
+ "platformVersion",
73
+ "uaFullVersion"])
74
+ .then(ua => {
75
+ dimension.log(Dimension.Platform, ua.platform);
76
+ dimension.log(Dimension.PlatformVersion, ua.platformVersion);
77
+ ua.brands?.forEach(brand => {
78
+ dimension.log(Dimension.Brand, brand.name + Constant.Tilde + brand.version);
79
+ });
80
+ dimension.log(Dimension.Model, ua.model);
81
+ metric.max(Metric.Mobile, ua.mobile ? BooleanFlag.True : BooleanFlag.False);
82
+ });
83
+ }
84
+ }
85
+
67
86
  export function stop(): void {
68
87
  callback = null;
69
88
  rootDomain = null;
89
+ data = null;
70
90
  }
71
91
 
72
- export function metadata(cb: MetadataCallback): void {
73
- callback = cb;
92
+ export function metadata(cb: MetadataCallback, wait: boolean = true): void {
93
+ if (data && wait === false) {
94
+ // Immediately invoke the callback if the caller explicitly doesn't want to wait for the upgrade confirmation
95
+ cb(data, !config.lean);
96
+ } else {
97
+ // Save the callback for future reference; so we can inform the caller when page gets upgraded and we have a valid playback flag
98
+ callback = cb;
99
+ }
74
100
  }
75
101
 
76
102
  export function id(): string {
@@ -28,7 +28,8 @@ export function log(code: Code, severity: Severity, name: string = null, message
28
28
 
29
29
  function csp(e: SecurityPolicyViolationEvent): void {
30
30
  let upload = config.upload as string;
31
- let parts = upload ? upload.substr(0, upload.indexOf("/", Constant.HTTPS.length)).split(Constant.Dot) : []; // Look for first "/" starting after initial "https://" string
31
+ // Look for first "/" starting after initial "https://" string
32
+ let parts = upload && typeof upload === Constant.String ? upload.substr(0, upload.indexOf("/", Constant.HTTPS.length)).split(Constant.Dot) : [];
32
33
  let domain = parts.length >= 2 ? parts.splice(-2).join(Constant.Dot) : null;
33
34
  // Capture content security policy violation only if disposition value is not explicitly set to "report"
34
35
  if (domain && e.blockedURI && e.blockedURI.indexOf(domain) >= 0 && e["disposition"] !== Constant.Report) {
package/src/layout/dom.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Privacy } from "@clarity-types/core";
2
2
  import { Code, Setting, Severity } from "@clarity-types/data";
3
- import { Constant, NodeInfo, NodeValue, SelectorInput, Source } from "@clarity-types/layout";
3
+ import { Constant, NodeInfo, NodeValue, Selector, SelectorInput, Source } from "@clarity-types/layout";
4
4
  import config from "@src/core/config";
5
5
  import hash from "@src/core/hash";
6
6
  import * as internal from "@src/diagnostic/internal";
@@ -12,13 +12,15 @@ let index: number = 1;
12
12
 
13
13
  // Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#%3Cinput%3E_types
14
14
  const DISALLOWED_TYPES = ["password", "hidden", "email", "tel"];
15
- const DISALLOWED_NAMES = ["addr", "cell", "code", "dob", "email", "mob", "name", "phone", "secret", "social", "ssn", "tel", "zip", "pass"];
15
+ const DISALLOWED_NAMES = ["addr", "cell", "code", "dob", "email", "mob", "name", "phone", "secret", "social", "ssn", "tel", "zip", "pass", "card", "account", "cvv", "ccv"];
16
16
  const DISALLOWED_MATCH = ["address", "password", "contact"];
17
17
 
18
18
  let nodes: Node[] = [];
19
19
  let values: NodeValue[] = [];
20
20
  let updateMap: number[] = [];
21
21
  let hashMap: { [hash: string]: number } = {};
22
+ let override = [];
23
+ let unmask = [];
22
24
 
23
25
  // The WeakMap object is a collection of key/value pairs in which the keys are weakly referenced
24
26
  let idMap: WeakMap<Node, number> = null; // Maps node => id.
@@ -27,7 +29,7 @@ let privacyMap: WeakMap<Node, Privacy> = null; // Maps node => Privacy (enum)
27
29
 
28
30
  export function start(): void {
29
31
  reset();
30
- parse(document);
32
+ parse(document, true);
31
33
  }
32
34
 
33
35
  export function stop(): void {
@@ -40,6 +42,8 @@ function reset(): void {
40
42
  values = [];
41
43
  updateMap = [];
42
44
  hashMap = {};
45
+ override = [];
46
+ unmask = [];
43
47
  idMap = new WeakMap();
44
48
  iframeMap = new WeakMap();
45
49
  privacyMap = new WeakMap();
@@ -47,10 +51,13 @@ function reset(): void {
47
51
 
48
52
  // We parse new root nodes for any regions or masked nodes in the beginning (document) and
49
53
  // later whenever there are new additions or modifications to DOM (mutations)
50
- export function parse(root: ParentNode): void {
54
+ export function parse(root: ParentNode, init: boolean = false): void {
51
55
  // Wrap selectors in a try / catch block.
52
56
  // It's possible for script to receive invalid selectors, e.g. "'#id'" with extra quotes, and cause the code below to fail
53
57
  try {
58
+ // Parse unmask configuration into separate query selectors and override tokens as part of initialization
59
+ if (init) { config.unmask.forEach(x => x.indexOf(Constant.Bang) < 0 ? unmask.push(x) : override.push(x.substr(1))); }
60
+
54
61
  // Since mutations may happen on leaf nodes too, e.g. text nodes, which may not support all selector APIs.
55
62
  // We ensure that the root note supports querySelectorAll API before executing the code below to identify new regions.
56
63
  if ("querySelectorAll" in root) {
@@ -58,7 +65,7 @@ export function parse(root: ParentNode): void {
58
65
  extract.metrics(root, config.metrics);
59
66
  extract.dimensions(root, config.dimensions);
60
67
  config.mask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.TextImage))); // Masked Elements
61
- config.unmask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.None))); // Unmasked Elements
68
+ unmask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.None))); // Unmasked Elements
62
69
  }
63
70
  } catch (e) { internal.log(Code.Selector, Severity.Warning, e ? e.name : null); }
64
71
  }
@@ -80,19 +87,17 @@ export function add(node: Node, parent: Node, data: NodeInfo, source: Source): v
80
87
  let previousId = getPreviousId(node);
81
88
  let privacy = config.content ? Privacy.Sensitive : Privacy.Text;
82
89
  let parentValue = null;
83
- let parentTag = Constant.Empty;
84
90
  let regionId = region.exists(node) ? id : null;
85
91
 
86
92
  if (parentId >= 0 && values[parentId]) {
87
93
  parentValue = values[parentId];
88
- parentTag = parentValue.data.tag;
89
94
  parentValue.children.push(id);
90
95
  regionId = regionId === null ? parentValue.region : regionId;
91
96
  privacy = parentValue.metadata.privacy;
92
97
  }
93
98
 
94
99
  // Check to see if this particular node should be masked or not
95
- privacy = getPrivacy(node, data, parentTag, privacy);
100
+ privacy = getPrivacy(node, data, parentValue, privacy);
96
101
 
97
102
  // If there's an explicit region attribute set on the element, use it to mark a region on the page
98
103
  if (data.attributes && Constant.RegionData in data.attributes) {
@@ -198,13 +203,27 @@ export function iframe(node: Node): HTMLIFrameElement {
198
203
  return doc && iframeMap.has(doc) ? iframeMap.get(doc) : null;
199
204
  }
200
205
 
201
- function getPrivacy(node: Node, data: NodeInfo, parentTag: string, privacy: Privacy): Privacy {
206
+ function getPrivacy(node: Node, data: NodeInfo, parent: NodeValue, privacy: Privacy): Privacy {
202
207
  let attributes = data.attributes;
203
208
  let tag = data.tag.toUpperCase();
204
209
 
205
210
  // If this node was explicitly configured to contain sensitive content, use that information and return the value
206
211
  if (privacyMap.has(node)) { return privacyMap.get(node); }
207
212
 
213
+ // If it's a text node belonging to a STYLE or TITLE tag;
214
+ // Or, the text node belongs to one of SCRUB_EXCEPTIONS
215
+ // then reset the privacy setting to ensure we capture the content
216
+ if (tag === Constant.TextTag && parent && parent.data) {
217
+ let path = parent.selector ? parent.selector[Selector.Stable] : Constant.Empty;
218
+ privacy = parent.data.tag === Constant.StyleTag || parent.data.tag === Constant.TitleTag ? Privacy.None : privacy;
219
+ for (let entry of override) {
220
+ if (path.indexOf(entry) >= 0) {
221
+ privacy = Privacy.None;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+
208
227
  // Do not proceed if attributes are missing for the node
209
228
  if (attributes === null || attributes === undefined) { return privacy; }
210
229
 
@@ -243,10 +262,6 @@ function getPrivacy(node: Node, data: NodeInfo, parentTag: string, privacy: Priv
243
262
  if (Constant.MaskData in attributes) { privacy = Privacy.TextImage; }
244
263
  if (Constant.UnmaskData in attributes) { privacy = Privacy.None; }
245
264
 
246
- // If it's a text node belonging to a STYLE or TITLE tag; then reset the privacy setting to ensure we capture the content
247
- let cTag = tag === Constant.TextTag ? parentTag : tag;
248
- if (cTag === Constant.StyleTag || cTag === Constant.TitleTag) { privacy = Privacy.None; }
249
-
250
265
  return privacy;
251
266
  }
252
267
 
@@ -36,7 +36,7 @@ export function start(): void {
36
36
 
37
37
  if (insertRule === null) { insertRule = CSSStyleSheet.prototype.insertRule; }
38
38
  if (deleteRule === null) { deleteRule = CSSStyleSheet.prototype.deleteRule; }
39
- if (attachShadow === null) { attachShadow = HTMLElement.prototype.attachShadow; }
39
+ if (attachShadow === null) { attachShadow = Element.prototype.attachShadow; }
40
40
 
41
41
  // Some popular open source libraries, like styled-components, optimize performance
42
42
  // by injecting CSS using insertRule API vs. appending text node. A side effect of
@@ -56,7 +56,7 @@ export function start(): void {
56
56
  // In case we are unable to add a hook and browser throws an exception,
57
57
  // reset attachShadow variable and resume processing like before
58
58
  try {
59
- HTMLElement.prototype.attachShadow = function (): ShadowRoot {
59
+ Element.prototype.attachShadow = function (): ShadowRoot {
60
60
  return schedule(attachShadow.apply(this, arguments)) as ShadowRoot;
61
61
  }
62
62
  } catch { attachShadow = null; }
@@ -107,7 +107,7 @@ export function stop(): void {
107
107
 
108
108
  // Restoring original attachShadow
109
109
  if (attachShadow != null) {
110
- HTMLElement.prototype.attachShadow = attachShadow;
110
+ Element.prototype.attachShadow = attachShadow;
111
111
  attachShadow = null;
112
112
  }
113
113
 
@@ -145,6 +145,7 @@ async function process(): Promise<void> {
145
145
  let target = mutation.target;
146
146
  let type = track(mutation, timer);
147
147
  if (type && target && target.ownerDocument) { dom.parse(target.ownerDocument); }
148
+ if (type && target && target.nodeType == Node.DOCUMENT_FRAGMENT_NODE && (target as ShadowRoot).host) { dom.parse(target as ShadowRoot); }
148
149
  switch (type) {
149
150
  case Constant.Attributes:
150
151
  processNode(target, Source.Attributes);
@@ -46,6 +46,7 @@ export default function (node: Node, source: Source): Node {
46
46
  case Node.DOCUMENT_FRAGMENT_NODE:
47
47
  let shadowRoot = (node as ShadowRoot);
48
48
  if (shadowRoot.host) {
49
+ dom.parse(shadowRoot);
49
50
  let type = typeof (shadowRoot.constructor);
50
51
  if (type === Constant.Function && shadowRoot.constructor.toString().indexOf(Constant.NativeCode) >= 0) {
51
52
  observe(shadowRoot);
@@ -24,12 +24,13 @@ export default function(input: SelectorInput, beta: boolean = false): string {
24
24
  let selector = `${prefix}${input.tag}${suffix}`;
25
25
  let classes = Constant.Class in a && a[Constant.Class].length > 0 ? a[Constant.Class].trim().split(/\s+/) : null;
26
26
  if (beta) {
27
- // In beta mode, update selector to use "id" field when available
28
- // The only exception is if "id" appears to be an auto generated string token, e.g. guid or a random id
27
+ // In beta mode, update selector to use "id" field when available. There are two exceptions:
28
+ // (1) if "id" appears to be an auto generated string token, e.g. guid or a random id containing digits
29
+ // (2) if "id" appears inside a shadow DOM, in which case we continue to prefix up to shadow DOM to prevent conflicts
29
30
  let id = Constant.Id in a && a[Constant.Id].length > 0 ? a[Constant.Id] : null;
30
31
  classes = input.tag !== Constant.BodyTag && classes ? classes.filter(c => !hasDigits(c)) : [];
31
32
  selector = classes.length > 0 ? `${prefix}${input.tag}.${classes.join(".")}${suffix}` : selector;
32
- selector = id && hasDigits(id) === false ? `#${id}` : selector;
33
+ selector = id && hasDigits(id) === false ? `${getDomPrefix(prefix)}#${id}` : selector;
33
34
  } else {
34
35
  // Otherwise, fallback to stable mode, where we include class names as part of the selector
35
36
  selector = classes ? `${prefix}${input.tag}.${classes.join(".")}${suffix}` : selector;
@@ -38,11 +39,24 @@ export default function(input: SelectorInput, beta: boolean = false): string {
38
39
  }
39
40
  }
40
41
 
42
+ function getDomPrefix(prefix: string): string {
43
+ const shadowDomStart = prefix.lastIndexOf(Constant.ShadowDomTag);
44
+ const iframeDomStart = prefix.lastIndexOf(`${Constant.IFramePrefix}${Constant.HTML}`);
45
+ const domStart = Math.max(shadowDomStart, iframeDomStart);
46
+
47
+ if (domStart < 0) {
48
+ return "";
49
+ }
50
+
51
+ const domEnd = prefix.indexOf(">", domStart) + 1;
52
+ return prefix.substr(0, domEnd);
53
+ }
54
+
41
55
  // Check if the given input string has digits or not
42
56
  function hasDigits(value: string): boolean {
43
57
  for (let i = 0; i < value.length; i++) {
44
58
  let c = value.charCodeAt(i);
45
- return c >= Character.Zero && c <= Character.Nine;
59
+ if (c >= Character.Zero && c <= Character.Nine) { return true };
46
60
  }
47
61
  return false;
48
62
  }
package/test/helper.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Core, Data, Layout } from "clarity-decode";
2
2
  import * as fs from 'fs';
3
+ import * as url from 'url';
3
4
  import * as path from 'path';
4
5
  import { Browser, Page, chromium } from 'playwright';
5
6
 
@@ -8,7 +9,13 @@ export async function launch(): Promise<Browser> {
8
9
  }
9
10
 
10
11
  export async function markup(page: Page, file: string, override: Core.Config = null): Promise<string[]> {
11
- const html = fs.readFileSync(path.resolve(__dirname, `./html/${file}`), 'utf8');
12
+ const htmlPath = path.resolve(__dirname, `./html/${file}`);
13
+ const htmlFileUrl = url.pathToFileURL(htmlPath).toString();
14
+ const html = fs.readFileSync(htmlPath, 'utf8');
15
+ await Promise.all([
16
+ page.goto(htmlFileUrl),
17
+ page.waitForNavigation()
18
+ ]);
12
19
  await page.setContent(html.replace("</body>", `
13
20
  <script>
14
21
  window.payloads = [];
@@ -17,6 +24,7 @@ export async function markup(page: Page, file: string, override: Core.Config = n
17
24
  </script>
18
25
  </body>
19
26
  `));
27
+ await page.hover("#two");
20
28
  await page.waitForFunction("payloads && payloads.length > 1");
21
29
  return await page.evaluate('payloads');
22
30
  }
@@ -34,7 +42,7 @@ export function node(decoded: Data.DecodedPayload[], key: string, value: string
34
42
  }
35
43
 
36
44
  // Walking over the decoded payload to find the right match
37
- for (let i = decoded.length - 1; i > 0; i--) {
45
+ for (let i = decoded.length - 1; i >= 0; i--) {
38
46
  if (decoded[i].dom) {
39
47
  for (let j = 0; j < decoded[i].dom.length; j++) {
40
48
  if (decoded[i].dom[j].data) {
package/types/core.d.ts CHANGED
@@ -101,12 +101,13 @@ export interface BrowserEvent {
101
101
  }
102
102
 
103
103
  export interface Report {
104
- c: Data.Check; // Reporting code
104
+ v: string; // Version
105
105
  p: string; // Project Id
106
106
  u: string; // User Id
107
107
  s: string; // Session Id
108
108
  n: number; // Page Number
109
109
  m?: string; // Message, optional
110
+ e?: string; // Error Stack, optional
110
111
  }
111
112
 
112
113
  export interface Config {
package/types/data.d.ts CHANGED
@@ -84,7 +84,8 @@ export const enum Metric {
84
84
  CartTax = 23,
85
85
  CartTotal = 24,
86
86
  EventCount = 25,
87
- Automation = 26
87
+ Automation = 26,
88
+ Mobile = 27
88
89
  }
89
90
 
90
91
  export const enum Dimension {
@@ -109,7 +110,11 @@ export const enum Dimension {
109
110
  Headline = 18,
110
111
  MetaType = 19,
111
112
  MetaTitle = 20,
112
- Generator = 21
113
+ Generator = 21,
114
+ Platform = 22,
115
+ PlatformVersion = 23,
116
+ Brand = 24,
117
+ Model = 25
113
118
  }
114
119
 
115
120
  export const enum Check {
@@ -238,7 +243,8 @@ export const enum Constant {
238
243
  HTTPS = "https://",
239
244
  CompressionStream = "CompressionStream",
240
245
  Accept = "Accept",
241
- ClarityGzip = "application/x-clarity-gzip"
246
+ ClarityGzip = "application/x-clarity-gzip",
247
+ Tilde = "~",
242
248
  }
243
249
 
244
250
  export const enum XMLReadyState {
package/types/index.d.ts CHANGED
@@ -15,7 +15,7 @@ interface Clarity {
15
15
  event: (name: string, value: string) => void;
16
16
  set: (variable: string, value: string | string[]) => void;
17
17
  identify: (userId: string, sessionId?: string, pageId?: string) => void;
18
- metadata: (callback: Data.MetadataCallback) => void;
18
+ metadata: (callback: Data.MetadataCallback, wait?: boolean) => void;
19
19
  }
20
20
 
21
21
  interface Helper {
package/types/layout.d.ts CHANGED
@@ -40,6 +40,7 @@ export const enum Constant {
40
40
  Src = "src",
41
41
  Srcset = "srcset",
42
42
  Box = "#",
43
+ Bang = "!",
43
44
  Period = ".",
44
45
  MaskData = "data-clarity-mask",
45
46
  UnmaskData = "data-clarity-unmask",