From afef6ad6a551e40618583658b45b389fd9441eb9 Mon Sep 17 00:00:00 2001 From: Sarvesh Nagpal Date: Thu, 12 Aug 2021 09:55:33 -0700 Subject: [PATCH] Add support for a fallback upload URL (#163) * Add support for a fallback upload URL * Bump client version to 0.6.21 * Refactor report module and PR feedback * Fixing security alert * Fix comments * Minimizing cookie churn and improving logic to find root domain * Address PR feedback * Bug fix --- lerna.json | 2 +- package.json | 4 +- packages/clarity-decode/package.json | 4 +- packages/clarity-devtools/package.json | 8 +-- .../clarity-devtools/static/manifest.json | 4 +- packages/clarity-js/package.json | 2 +- packages/clarity-js/src/core/config.ts | 2 +- packages/clarity-js/src/core/history.ts | 4 +- packages/clarity-js/src/core/report.ts | 10 ++- packages/clarity-js/src/core/task.ts | 4 +- packages/clarity-js/src/core/version.ts | 2 +- packages/clarity-js/src/data/envelope.ts | 2 - packages/clarity-js/src/data/limit.ts | 2 +- packages/clarity-js/src/data/metadata.ts | 71 +++++++++++-------- packages/clarity-js/src/data/upload.ts | 53 ++++++++------ packages/clarity-js/src/diagnostic/encode.ts | 14 ++-- packages/clarity-js/src/diagnostic/index.ts | 6 +- .../clarity-js/src/diagnostic/internal.ts | 40 +++++++++++ packages/clarity-js/src/diagnostic/log.ts | 30 -------- packages/clarity-js/src/layout/dom.ts | 4 +- packages/clarity-js/src/layout/mutation.ts | 4 +- packages/clarity-js/src/layout/node.ts | 6 +- .../clarity-js/src/performance/observer.ts | 6 +- packages/clarity-js/types/core.d.ts | 8 ++- packages/clarity-js/types/data.d.ts | 10 ++- packages/clarity-visualize/package.json | 4 +- yarn.lock | 55 ++------------ 27 files changed, 181 insertions(+), 180 deletions(-) create mode 100644 packages/clarity-js/src/diagnostic/internal.ts delete mode 100644 packages/clarity-js/src/diagnostic/log.ts diff --git a/lerna.json b/lerna.json index d11b2987..5d381067 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "packages": [ "packages/*" ], - "version": "0.6.20", + "version": "0.6.21", "npmClient": "yarn", "useWorkspaces": true } diff --git a/package.json b/package.json index 054358e5..0ec0780b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "clarity", "private": true, - "version": "0.6.20", + "version": "0.6.21", "repository": "https://github.com/microsoft/clarity.git", "author": "Sarvesh Nagpal ", "license": "MIT", @@ -21,9 +21,11 @@ }, "devDependencies": { "lerna": "^4.0.0", + "tar": "^6.1.7", "trim-newlines": "^4.0.2" }, "resolutions": { + "tar": "^6.1.7", "trim-newlines": "^4.0.2" } } diff --git a/packages/clarity-decode/package.json b/packages/clarity-decode/package.json index c2555fb8..fc5aeadd 100644 --- a/packages/clarity-decode/package.json +++ b/packages/clarity-decode/package.json @@ -1,6 +1,6 @@ { "name": "clarity-decode", - "version": "0.6.20", + "version": "0.6.21", "description": "An analytics library that uses web page interactions to generate aggregated insights", "author": "Microsoft Corp.", "license": "MIT", @@ -26,7 +26,7 @@ "url": "https://github.com/Microsoft/clarity/issues" }, "dependencies": { - "clarity-js": "^0.6.20" + "clarity-js": "^0.6.21" }, "devDependencies": { "@rollup/plugin-commonjs": "^19.0.1", diff --git a/packages/clarity-devtools/package.json b/packages/clarity-devtools/package.json index ae4a54f9..0f7bb4c6 100644 --- a/packages/clarity-devtools/package.json +++ b/packages/clarity-devtools/package.json @@ -1,6 +1,6 @@ { "name": "clarity-devtools", - "version": "0.6.20", + "version": "0.6.21", "private": true, "description": "Adds Clarity debugging support to browser devtools", "author": "Microsoft Corp.", @@ -24,9 +24,9 @@ "url": "https://github.com/Microsoft/clarity/issues" }, "dependencies": { - "clarity-decode": "^0.6.20", - "clarity-js": "^0.6.20", - "clarity-visualize": "^0.6.20" + "clarity-decode": "^0.6.21", + "clarity-js": "^0.6.21", + "clarity-visualize": "^0.6.21" }, "devDependencies": { "@rollup/plugin-node-resolve": "^13.0.2", diff --git a/packages/clarity-devtools/static/manifest.json b/packages/clarity-devtools/static/manifest.json index 7f05c46b..e4fae2da 100644 --- a/packages/clarity-devtools/static/manifest.json +++ b/packages/clarity-devtools/static/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 2, "name": "Clarity Developer Tools", "description": "Get insights about how customers use your website.", - "version": "0.6.20", - "version_name": "0.6.20", + "version": "0.6.21", + "version_name": "0.6.21", "minimum_chrome_version": "50", "devtools_page": "devtools.html", "icons": { diff --git a/packages/clarity-js/package.json b/packages/clarity-js/package.json index 4abe2466..3c03b7f1 100644 --- a/packages/clarity-js/package.json +++ b/packages/clarity-js/package.json @@ -1,6 +1,6 @@ { "name": "clarity-js", - "version": "0.6.20", + "version": "0.6.21", "description": "An analytics library that uses web page interactions to generate aggregated insights", "author": "Microsoft Corp.", "license": "MIT", diff --git a/packages/clarity-js/src/core/config.ts b/packages/clarity-js/src/core/config.ts index 6b52ddef..6f1b485b 100644 --- a/packages/clarity-js/src/core/config.ts +++ b/packages/clarity-js/src/core/config.ts @@ -12,9 +12,9 @@ let config: Config = { regions: [], metrics: [], cookies: [], - server: null, report: null, upload: null, + fallback: null, upgrade: null }; diff --git a/packages/clarity-js/src/core/history.ts b/packages/clarity-js/src/core/history.ts index fc6d92ac..1046f3d8 100644 --- a/packages/clarity-js/src/core/history.ts +++ b/packages/clarity-js/src/core/history.ts @@ -1,7 +1,7 @@ import { Code, Constant, Setting, Severity } from "@clarity-types/data"; import * as clarity from "@src/clarity"; import { bind } from "@src/core/event"; -import * as log from "@src/diagnostic/log"; +import * as internal from "@src/diagnostic/internal"; let pushState = null; let replaceState = null; @@ -34,7 +34,7 @@ export function start(): void { function check(): boolean { if (count++ > Setting.CallStackDepth) { - log.log(Code.CallStackDepth, null, Severity.Info); + internal.log(Code.CallStackDepth, Severity.Info); return false; } return true; diff --git a/packages/clarity-js/src/core/report.ts b/packages/clarity-js/src/core/report.ts index 68ccc4b5..46d9ad47 100644 --- a/packages/clarity-js/src/core/report.ts +++ b/packages/clarity-js/src/core/report.ts @@ -1,4 +1,5 @@ import { Report } from "@clarity-types/core"; +import { Check } from "@clarity-types/data"; import config from "@src/core/config"; import { data } from "@src/data/metadata"; @@ -8,15 +9,18 @@ export function reset(): void { history = []; } -export function report(message: string): void { +export function report(check: Check, message: string = null): void { // Do not report the same message twice for the same page if (history && history.indexOf(message) === -1) { const url = config.report; if (url && url.length > 0) { - let payload = JSON.stringify({m: message, p: data.projectId, u: data.userId, s: data.sessionId, n: data.pageNum} as Report); + let payload: Report = {c: check, p: data.projectId, u: data.userId, s: data.sessionId, n: data.pageNum }; + if (message) payload.m = message; + // Using POST request instead of a GET request (img-src) to not violate existing CSP rules + // Since, Clarity already uses XHR to upload data, we stick with similar POST mechanism for reporting too let xhr = new XMLHttpRequest(); xhr.open("POST", url); - xhr.send(payload); + xhr.send(JSON.stringify(payload)); history.push(message); } } diff --git a/packages/clarity-js/src/core/task.ts b/packages/clarity-js/src/core/task.ts index fe491df8..9d1fbb55 100644 --- a/packages/clarity-js/src/core/task.ts +++ b/packages/clarity-js/src/core/task.ts @@ -3,7 +3,7 @@ import { Setting, TaskFunction, TaskResolve, Tasks } from "@clarity-types/core"; import { Code, Metric, Severity } from "@clarity-types/data"; import * as metadata from "@src/data/metadata"; import * as metric from "@src/data/metric"; -import * as log from "@src/diagnostic/log"; +import * as internal from "@src/diagnostic/internal"; // Track the start time to be able to compute duration at the end of the task const idleTimeout = 5000; @@ -74,7 +74,7 @@ function run(): void { }).catch((error: Error): void => { // If one of the scheduled tasks failed, log, recover and continue processing rest of the tasks if (entry.id !== metadata.id()) { return; } - log.log(Code.RunTask, error, Severity.Warning); + if (error) { internal.log(Code.RunTask, Severity.Warning, error.name, error.message, error.stack); } activeTask = null; run(); }); diff --git a/packages/clarity-js/src/core/version.ts b/packages/clarity-js/src/core/version.ts index 4016f1e5..fd28f344 100644 --- a/packages/clarity-js/src/core/version.ts +++ b/packages/clarity-js/src/core/version.ts @@ -1,2 +1,2 @@ -let version = "0.6.20"; +let version = "0.6.21"; export default version; diff --git a/packages/clarity-js/src/data/envelope.ts b/packages/clarity-js/src/data/envelope.ts index ea9c0a61..d7a572d7 100644 --- a/packages/clarity-js/src/data/envelope.ts +++ b/packages/clarity-js/src/data/envelope.ts @@ -26,8 +26,6 @@ export function stop(): void { } export function envelope(last: boolean): Token[] { - // Update the session storage once we are ready to send our first payload back to the server - if (data.sequence === 0) { metadata.save(); } data.start = data.start + data.duration; data.duration = time() - data.start; data.sequence++; diff --git a/packages/clarity-js/src/data/limit.ts b/packages/clarity-js/src/data/limit.ts index 7f771253..1d0bd1f4 100644 --- a/packages/clarity-js/src/data/limit.ts +++ b/packages/clarity-js/src/data/limit.ts @@ -25,7 +25,7 @@ export function check(bytes: number): void { } export function trigger(reason: Check): void { - report(`Limit #${reason}`); + report(reason); data.check = reason; metadata.clear(); clarity.stop(); diff --git a/packages/clarity-js/src/data/metadata.ts b/packages/clarity-js/src/data/metadata.ts index b440d97c..025fffd9 100644 --- a/packages/clarity-js/src/data/metadata.ts +++ b/packages/clarity-js/src/data/metadata.ts @@ -1,4 +1,5 @@ -import { BooleanFlag, Constant, Dimension, Metadata, MetadataCallback, Metric, Session, Setting } from "@clarity-types/data"; +import { Time } from "@clarity-types/core"; +import { BooleanFlag, Constant, Dimension, Metadata, MetadataCallback, Metric, Session, User, Setting } from "@clarity-types/data"; import * as core from "@src/core"; import config from "@src/core/config"; import hash from "@src/core/hash"; @@ -18,26 +19,17 @@ export function start(): void { // Populate ids for this page let s = session(); + let u = user(); data = { projectId: config.projectId || hash(location.host), - userId: user(), + userId: u.id, sessionId: s.session, pageNum: s.count } - // The code below checks if the "upload" value is complete URL, and if so, break it into "server" and "upload" - if (config.upload && typeof config.upload === Constant.String && (config.upload as string).indexOf(Constant.HTTPS) === 0) { - let url = config.upload as string; - config.server = url.substr(0, url.indexOf("/", Constant.HTTPS.length)); // Look for first "/" starting after initial "https://" string - config.upload = config.server.length > 0 && config.server.length < url.length ? url.substr(config.server.length + 1) : url; // Grab path of the url and update "upload" configuration - } - // Override configuration based on what's in the session storage config.lean = config.track && s.upgrade !== null ? s.upgrade === BooleanFlag.False : config.lean; config.upload = config.track && typeof config.upload === Constant.String && s.upload ? s.upload : config.upload; - config.server = config.track && s.server ? Constant.HTTPS + s.server : config.server; - - // Log dimensions dimension.log(Dimension.UserAgent, ua); dimension.log(Dimension.PageTitle, title); @@ -67,7 +59,7 @@ export function start(): void { } // Track ids using a cookie if configuration allows it - track(); + track(u.expiry); } export function stop(): void { @@ -86,7 +78,7 @@ export function id(): string { export function consent(): void { if (core.active()) { config.track = true; - track(); + track((user()).expiry); } } @@ -108,18 +100,33 @@ function tab(): string { export function save(): void { let ts = Math.round(Date.now()); let upgrade = config.lean ? BooleanFlag.False : BooleanFlag.True; - let upload = typeof config.upload === Constant.String ? config.upload : Constant.Empty; - let server = config.server ? config.server.replace(Constant.HTTPS, Constant.Empty) : Constant.Empty; + let upload = config.upload && typeof config.upload === Constant.String ? config.upload as string : Constant.Empty; + let host: string = Constant.Empty; + let path: string = Constant.Empty; + + // The code below checks if the "upload" value is a string, and if so, break it into "host" and "path" before writing to session cookie + // This is for forward compatibility - to be removed in future versions (v0.6.21) + if (upload) { + host = upload.substr(0, upload.indexOf("/", Constant.HTTPS.length)); // Look for first "/" starting after initial "https://" string + path = host.length > 0 && host.length < upload.length ? upload.substr(host.length + 1) : upload; // Grab path of the url and update host value + host = host.replace(Constant.HTTPS, Constant.Empty); + } if (upgrade && callback) { callback(data, !config.lean); } - setCookie(Constant.SessionKey, [data.sessionId, ts, data.pageNum, upgrade, upload, server].join(Constant.Pipe), Setting.SessionExpire); + setCookie(Constant.SessionKey, [data.sessionId, ts, data.pageNum, upgrade, path, host].join(Constant.Pipe), Setting.SessionExpire); } function supported(target: Window | Document, api: string): boolean { try { return !!target[api]; } catch { return false; } } -function track(): void { - setCookie(Constant.CookieKey, `${data.userId}${Constant.Pipe}${Setting.CookieVersion}`, Setting.Expire); +function track(expiry: number): void { + // Convert time precision into days to reduce number of bytes we have to write in a cookie + // E.g. Math.ceil(1628735962643 / (24*60*60*1000)) => 18852 (days) => ejo in base36 (13 bytes => 3 bytes) + let end = Math.ceil((Date.now() + (Setting.Expire * Time.Day))/Time.Day); + // To avoid cookie churn, write user id cookie only once every day + if (expiry === null || Math.abs(end - expiry) >= Setting.CookieInterval) { + setCookie(Constant.CookieKey, [data.userId, Setting.CookieVersion, end.toString(36)].join(Constant.Pipe), Setting.Expire); + } } function shortid(): string { @@ -131,17 +138,17 @@ function shortid(): string { } function session(): Session { - let output: Session = { session: shortid(), ts: Math.round(Date.now()), count: 1, upgrade: null, upload: Constant.Empty, server: Constant.Empty }; + let output: Session = { session: shortid(), ts: Math.round(Date.now()), count: 1, upgrade: null, upload: Constant.Empty }; let value = getCookie(Constant.SessionKey); if (value) { let parts = value.split(Constant.Pipe); - // Making it backward & forward compatible by using greater than comparison + // Making it backward & forward compatible by using greater than comparison (v0.6.21) + // In future version, we can reduce the parts length to be 5 where the last part contains the full upload URL if (parts.length >= 5 && output.ts - num(parts[1]) < Setting.SessionTimeout) { output.session = parts[0]; output.count = num(parts[2]) + 1; output.upgrade = num(parts[3]); - output.upload = parts[4]; - output.server = parts.length >= 6 ? parts[5] : Constant.Empty; + output.upload = parts.length >= 6 ? `${Constant.HTTPS}${parts[5]}/${parts[4]}` : `${Constant.HTTPS}${parts[4]}`; } } return output; @@ -151,7 +158,8 @@ function num(string: string, base: number = 10): number { return parseInt(string, base); } -function user(): string { +function user(): User { + let output: User = { id: shortid(), expiry: null }; let cookie = getCookie(Constant.CookieKey); if(cookie && cookie.length > 0) { // Splitting and looking up first part for forward compatibility, in case we wish to store additional information in a cookie @@ -171,9 +179,11 @@ function user(): string { } // End code for backward compatibility // Return the existing userId, so it can eventually be written with version info later - return parts[0]; + output.id = parts[0]; + // Read version information and timestamp from cookie, if available + if (parts.length > 2) { output.expiry = num(parts[2], 36); } } - return shortid(); + return output; } function getCookie(key: string): string { @@ -209,10 +219,11 @@ function setCookie(key: string, value: string, time: number): void { if (i < hostname.length - 1) { // Write the cookie on the current computed top level domain document.cookie = `${cookie}${Constant.Semicolon}${Constant.Domain}${rootDomain}`; - // Once written, check if the cookie was set successfully - // If yes, no more action is required and we can return from the function since rootDomain cookie is already set - // If no, then continue with the for loop - if (getCookie(key)) { return; } + // Once written, check if the cookie exists and its value matches exactly with what we intended to set + // Checking for exact value match helps us eliminate a corner case where the cookie may already be present with a different value + // If the check is successful, no more action is required and we can return from the function since rootDomain cookie is already set + // If the check fails, continue with the for loop until we can successfully set and verify the cookie + if (getCookie(key) === value) { return; } } } // Finally, if we were not successful and gone through all the options, play it safe and reset rootDomain to be empty diff --git a/packages/clarity-js/src/data/upload.ts b/packages/clarity-js/src/data/upload.ts index dfb007f9..cae10b44 100644 --- a/packages/clarity-js/src/data/upload.ts +++ b/packages/clarity-js/src/data/upload.ts @@ -10,6 +10,7 @@ import encode from "@src/data/encode"; import * as envelope from "@src/data/envelope"; import * as data from "@src/data/index"; import * as limit from "@src/data/limit"; +import * as metadata from "@src/data/metadata"; import * as metric from "@src/data/metric"; import * as ping from "@src/data/ping"; import * as timeline from "@src/interaction/timeline"; @@ -136,10 +137,10 @@ function stringify(encoded: EncodedPayload): string { return encoded.p.length > 0 ? `{"e":${encoded.e},"a":${encoded.a},"p":${encoded.p}}` : `{"e":${encoded.e},"a":${encoded.a}}`; } -function send(payload: string, zipped: Uint8Array, sequence: number, beacon: boolean): void { +function send(payload: string, zipped: Uint8Array, sequence: number, beacon: boolean = false): void { // Upload data if a valid URL is defined in the config - if (typeof config.upload === Constant.String && config.server) { - const url = `${config.server}/${config.upload}`; + if (typeof config.upload === Constant.String) { + const url = config.upload as string; let dispatched = false; // If it's the last payload, attempt to upload using sendBeacon first. @@ -148,6 +149,7 @@ function send(payload: string, zipped: Uint8Array, sequence: number, beacon: boo // Also, in case of sendBeacon, we do not have a way to alter HTTP headers and therefore can't send compressed payload if (beacon && "sendBeacon" in navigator) { dispatched = navigator.sendBeacon(url, payload); + if (dispatched) { done(sequence); } } // Before initiating XHR upload, we check if the data has already been uploaded using sendBeacon @@ -161,7 +163,7 @@ function send(payload: string, zipped: Uint8Array, sequence: number, beacon: boo if (sequence in transit) { transit[sequence].attempts++; } else { transit[sequence] = { data: payload, attempts: 1 }; } let xhr = new XMLHttpRequest(); xhr.open("POST", url); - if (sequence !== null) { xhr.onreadystatechange = (): void => { measure(check)(xhr, sequence, beacon); }; } + if (sequence !== null) { xhr.onreadystatechange = (): void => { measure(check)(xhr, sequence); }; } xhr.withCredentials = true; if (zipped) { // If we do have valid compressed array, send it with appropriate HTTP headers so server can decode it appropriately @@ -175,30 +177,30 @@ function send(payload: string, zipped: Uint8Array, sequence: number, beacon: boo } else if (config.upload) { const callback = config.upload as UploadCallback; callback(payload); + done(sequence); } } -function check(xhr: XMLHttpRequest, sequence: number, last: boolean): void { +function check(xhr: XMLHttpRequest, sequence: number): void { var transitData = transit[sequence]; if (xhr && xhr.readyState === XMLReadyState.Done && transitData) { // Attempt send payload again (as configured in settings) if we do not receive a success (2XX) response code back from the server if ((xhr.status < 200 || xhr.status > 208) && transitData.attempts <= Setting.RetryLimit) { - // We re-attempt in all cases except two: - // 0: Indicates the browser has not put the request on the wire and therefore we need to attempt sendBeacon API before giving up - // 4XX: Indicates the server has rejected the response for bad payload and therefore we terminate the session - if (xhr.status === 0) { - // The observed behavior is that Safari will terminate pending XHR requests with status code 0 - // if the user navigates away from the page. In these cases, we fallback to the else clause and lose the data - // By explicitly handing status code 0 we attempt to try a different transport (sendBeacon vs. XHR) before giving up. - send(transitData.data, null, sequence, true); - } else if (xhr.status >= 400 && xhr.status < 500) { - // Anytime we receive a 4XX response from the server, we bail out instead of trying again + // We re-attempt in all cases except when server explicitly rejects our request with 4XX error + if (xhr.status >= 400 && xhr.status < 500) { + // In case of a 4XX response from the server, we bail out instead of trying again limit.trigger(Check.Server); } else { + // Browser will send status = 0 when it refuses to put network request over the wire + // This could happen for several reasons, couple of known ones are: + // 1: Browsers block upload because of content security policy violation + // 2: Safari will terminate pending XHR requests with status code 0 if the user navigates away from the page + // In any case, we switch the upload URL to fallback configuration (if available) before re-trying one more time + if (xhr.status === 0) { config.upload = config.fallback ? config.fallback : config.upload; } // In all other cases, re-attempt sending the same data // For retry we always fallback to string payload, even though we may have attempted // sending zipped payload earlier - send(transitData.data, null, sequence, last); + send(transitData.data, null, sequence); } } else { track = { sequence, attempts: transitData.attempts, status: xhr.status }; @@ -206,20 +208,31 @@ function check(xhr: XMLHttpRequest, sequence: number, last: boolean): void { if (transitData.attempts > 1) { encode(Event.Upload); } // Handle response if it was a 200 response with a valid body if (xhr.status === 200 && xhr.responseText) { response(xhr.responseText); } - // If we exhausted our retries then trigger Clarity shutdown for this page. - // The only exception is if browser decided to not even put the request on the network (status code: 0) - if (transitData.attempts > Setting.RetryLimit && xhr.status !== 0) { limit.trigger(Check.Retry); } + // If we exhausted our retries then trigger Clarity's shutdown for this page since the data will be incomplete + if (xhr.status === 0) { + // And, right before we terminate the session, we will attempt one last time to see if we can use + // different transport option (sendBeacon vs. XHR) to get this data to the server for analysis purposes + send(transitData.data, null, sequence, true); + limit.trigger(Check.Retry); + } + // Signal that this request completed successfully + if (xhr.status >= 200 && xhr.status <= 208) { done(sequence); } // Stop tracking this payload now that it's all done delete transit[sequence]; } } } +function done(sequence: number): void { + // If we everything went successfully, and it is the first sequence, save this session for future reference + if (sequence === 1) { metadata.save(); } +} + function delay(): number { // Progressively increase delay as we continue to send more payloads from the client to the server // If we are not uploading data to a server, and instead invoking UploadCallback, in that case keep returning configured value let gap = config.lean === false && discoverBytes > 0 ? Setting.MinUploadDelay : envelope.data.sequence * config.delay; - return config.server ? Math.max(Math.min(gap, Setting.MaxUploadDelay), Setting.MinUploadDelay) : config.delay; + return typeof config.upload === Constant.String ? Math.max(Math.min(gap, Setting.MaxUploadDelay), Setting.MinUploadDelay) : config.delay; } function response(payload: string): void { diff --git a/packages/clarity-js/src/diagnostic/encode.ts b/packages/clarity-js/src/diagnostic/encode.ts index add0e02e..5fead84c 100644 --- a/packages/clarity-js/src/diagnostic/encode.ts +++ b/packages/clarity-js/src/diagnostic/encode.ts @@ -2,7 +2,7 @@ import { Event, Token } from "@clarity-types/data"; import { time } from "@src/core/time"; import { queue } from "@src/data/upload"; import * as image from "@src/diagnostic/image"; -import * as log from "@src/diagnostic/log"; +import * as internal from "@src/diagnostic/internal"; import * as script from "@src/diagnostic/script"; import { metadata } from "@src/layout/target"; @@ -27,12 +27,12 @@ export default async function (type: Event): Promise { } break; case Event.Log: - if (log.data) { - tokens.push(log.data.code); - tokens.push(log.data.name); - tokens.push(log.data.message); - tokens.push(log.data.stack); - tokens.push(log.data.severity); + if (internal.data) { + tokens.push(internal.data.code); + tokens.push(internal.data.name); + tokens.push(internal.data.message); + tokens.push(internal.data.stack); + tokens.push(internal.data.severity); queue(tokens, false); } break; diff --git a/packages/clarity-js/src/diagnostic/index.ts b/packages/clarity-js/src/diagnostic/index.ts index 36441c00..41d6205c 100644 --- a/packages/clarity-js/src/diagnostic/index.ts +++ b/packages/clarity-js/src/diagnostic/index.ts @@ -1,14 +1,14 @@ import * as image from "./image"; -import * as log from "./log"; +import * as internal from "./internal"; import * as script from "./script"; export function start(): void { script.start(); image.start(); - log.reset(); + internal.start(); } export function stop(): void { image.stop(); - log.reset(); + internal.stop(); } diff --git a/packages/clarity-js/src/diagnostic/internal.ts b/packages/clarity-js/src/diagnostic/internal.ts new file mode 100644 index 00000000..5b8b1b4f --- /dev/null +++ b/packages/clarity-js/src/diagnostic/internal.ts @@ -0,0 +1,40 @@ +import { Code, Constant, Event, Severity } from "@clarity-types/data"; +import { LogData } from "@clarity-types/diagnostic"; +import config from "@src/core/config"; +import { bind } from "@src/core/event"; +import encode from "./encode"; + +let history: { [key: number]: string[] } = {}; +export let data: LogData; + +export function start(): void { + history = {}; + bind(document, "securitypolicyviolation", csp); +} + +export function log(code: Code, severity: Severity, name: string = null, message: string = null, stack: string = null): void { + let key = name ? `${name}|${message}`: ""; + // While rare, it's possible for code to fail repeatedly during the lifetime of the same page + // In those cases, we only want to log the failure once and not spam logs with redundant information. + if (code in history && history[code].indexOf(key) >= 0) { return; } + + data = { code, name, message, stack, severity }; + + // Maintain history of errors in memory to avoid sending redundant information + if (code in history) { history[code].push(key); } else { history[code] = [key]; } + + encode(Event.Log); +} + +function csp(e: SecurityPolicyViolationEvent): void { + let upload = config.upload as string; + let parts = upload ? upload.substr(0, upload.indexOf("/", Constant.HTTPS.length)).split(Constant.Dot) : []; // Look for first "/" starting after initial "https://" string + let domain = parts.length >= 2 ? parts.splice(-2).join(Constant.Dot) : null; + if (domain && e.blockedURI && e.blockedURI.indexOf(domain) >= 0) { + log(Code.ContentSecurityPolicy, Severity.Warning, e.blockedURI, `${e["disposition"]}`); + } +} + +export function stop(): void { + history = {}; +} diff --git a/packages/clarity-js/src/diagnostic/log.ts b/packages/clarity-js/src/diagnostic/log.ts deleted file mode 100644 index 0b7b9e6b..00000000 --- a/packages/clarity-js/src/diagnostic/log.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Code, Event, Severity } from "@clarity-types/data"; -import { LogData } from "@clarity-types/diagnostic"; -import encode from "./encode"; - -let history: { [key: number]: string[] } = {}; -export let data: LogData; - -export function log(code: Code, err: Error, severity: Severity = Severity.Warning): void { - let errorKey = err ? `${err.name}|${err.message}`: ""; - // While rare, it's possible for code to fail repeatedly during the lifetime of the same page - // In those cases, we only want to log the failure once and not spam logs with redundant information. - if (code in history && history[code].indexOf(errorKey) >= 0) { return; } - - data = { - code, - name: err ? err.name : null, - message: err ? err.message : null, - stack: err ? err.stack : null, - severity - }; - - // Maintain history of errors in memory to avoid sending redundant information - if (code in history) { history[code].push(errorKey); } else { history[code] = [errorKey]; } - - encode(Event.Log); -} - -export function reset(): void { - history = {}; -} \ No newline at end of file diff --git a/packages/clarity-js/src/layout/dom.ts b/packages/clarity-js/src/layout/dom.ts index f79c41de..05e88a0a 100644 --- a/packages/clarity-js/src/layout/dom.ts +++ b/packages/clarity-js/src/layout/dom.ts @@ -3,7 +3,7 @@ import { Code, Setting, Severity } from "@clarity-types/data"; import { Constant, NodeChange, NodeInfo, NodeValue, Source } from "@clarity-types/layout"; import config from "@src/core/config"; import { time } from "@src/core/time"; -import * as log from "@src/diagnostic/log"; +import * as internal from "@src/diagnostic/internal"; import * as extract from "@src/layout/extract"; import * as region from "@src/layout/region"; import selector from "@src/layout/selector"; @@ -65,7 +65,7 @@ export function parse(root: ParentNode): void { config.mask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.TextImage))); // Masked Elements config.unmask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.None))); // Unmasked Elements } - } catch (e) { log.log(Code.Selector, e, Severity.Warning); } + } catch (e) { internal.log(Code.Selector, Severity.Warning, e ? e.name : null); } } export function getId(node: Node, autogen: boolean = false): number { diff --git a/packages/clarity-js/src/layout/mutation.ts b/packages/clarity-js/src/layout/mutation.ts index 5b6f3bf4..a993b754 100644 --- a/packages/clarity-js/src/layout/mutation.ts +++ b/packages/clarity-js/src/layout/mutation.ts @@ -8,7 +8,7 @@ import { time } from "@src/core/time"; import { clearTimeout, setTimeout } from "@src/core/timeout"; import { id } from "@src/data/metadata"; import * as summary from "@src/data/summary"; -import * as log from "@src/diagnostic/log"; +import * as internal from "@src/diagnostic/internal"; import * as doc from "@src/layout/document"; import * as dom from "@src/layout/dom"; import encode from "@src/layout/encode"; @@ -66,7 +66,7 @@ export function observe(node: Node): void { observer.observe(node, { attributes: true, childList: true, characterData: true, subtree: true }); observers.push(observer); } - } catch (error) { log.log(Code.MutationObserver, error, Severity.Info); } + } catch (e) { internal.log(Code.MutationObserver, Severity.Info, e ? e.name : null); } } export function monitor(frame: HTMLIFrameElement): void { diff --git a/packages/clarity-js/src/layout/node.ts b/packages/clarity-js/src/layout/node.ts index 71ac9ceb..d094a9cc 100644 --- a/packages/clarity-js/src/layout/node.ts +++ b/packages/clarity-js/src/layout/node.ts @@ -2,7 +2,7 @@ import { Constant, Source } from "@clarity-types/layout"; import { Code, Severity } from "@clarity-types/data"; import config from "@src/core/config"; import * as dom from "./dom"; -import * as log from "@src/diagnostic/log"; +import * as internal from "@src/diagnostic/internal"; import * as interaction from "@src/interaction"; import * as mutation from "@src/layout/mutation"; import * as schema from "@src/layout/schema"; @@ -161,8 +161,8 @@ function getCssRules(sheet: CSSStyleSheet): string { let cssRules = null; // Firefox throws a SecurityError when trying to access cssRules of a stylesheet from a different domain try { cssRules = sheet ? sheet.cssRules : []; } catch (e) { - log.log(Code.CssRules, e, Severity.Warning); - if (e.name !== "SecurityError") { throw e; } + internal.log(Code.CssRules, Severity.Warning, e ? e.name : null); + if (e && e.name !== "SecurityError") { throw e; } } if (cssRules !== null) { diff --git a/packages/clarity-js/src/performance/observer.ts b/packages/clarity-js/src/performance/observer.ts index 772a3885..1a054a79 100644 --- a/packages/clarity-js/src/performance/observer.ts +++ b/packages/clarity-js/src/performance/observer.ts @@ -4,7 +4,7 @@ import measure from "@src/core/measure"; import { setTimeout } from "@src/core/timeout"; import * as dimension from "@src/data/dimension"; import * as metric from "@src/data/metric"; -import * as log from "@src/diagnostic/log"; +import * as internal from "@src/diagnostic/internal"; import * as navigation from "@src/performance/navigation"; let observer: PerformanceObserver; @@ -19,7 +19,7 @@ export function start(): void { if (document.readyState !== "complete") { bind(window, "load", setTimeout.bind(this, observe, 0)); } else { observe(); } - } else { log.log(Code.PerformanceObserver, null, Severity.Info); } + } else { internal.log(Code.PerformanceObserver, Severity.Info); } } function observe(): void { @@ -40,7 +40,7 @@ function observe(): void { observer.observe({type: x, buffered: true}); } } - } catch { log.log(Code.PerformanceObserver, null, Severity.Warning); } + } catch { internal.log(Code.PerformanceObserver, Severity.Warning); } } function handle(entries: PerformanceObserverEntryList): void { diff --git a/packages/clarity-js/types/core.d.ts b/packages/clarity-js/types/core.d.ts index 2ef9b29f..04859dcf 100644 --- a/packages/clarity-js/types/core.d.ts +++ b/packages/clarity-js/types/core.d.ts @@ -16,7 +16,8 @@ export const enum Priority { export const enum Time { Second = 1000, Minute = 60 * 1000, - Hour = 60 * 60 * 1000 + Hour = 60 * 60 * 1000, + Day = 24 * 60 * 60 * 1000 } @@ -99,11 +100,12 @@ export interface BrowserEvent { } export interface Report { - m: string; // Message + c: Data.Check; // Reporting code p: string; // Project Id u: string; // User Id s: string; // Session Id n: number; // Page Number + m?: string; // Message, optional } export interface Config { @@ -118,8 +120,8 @@ export interface Config { regions?: Region[]; metrics?: Metric[]; cookies?: string[]; - server?: string; report?: string; upload?: string | UploadCallback; + fallback?: string; upgrade?: (key: string) => void; } diff --git a/packages/clarity-js/types/data.d.ts b/packages/clarity-js/types/data.d.ts index e3991e94..459c0971 100644 --- a/packages/clarity-js/types/data.d.ts +++ b/packages/clarity-js/types/data.d.ts @@ -115,7 +115,8 @@ export const enum Code { PerformanceObserver = 3, CallStackDepth = 4, Selector = 5, - Metric = 6 + Metric = 6, + ContentSecurityPolicy = 7 } export const enum Severity { @@ -140,6 +141,7 @@ export const enum Setting { SessionExpire = 1, // 1 Day CookieVersion = 1, // Increment this version every time there's a cookie schema change SessionTimeout = 30 * Time.Minute, // 30 minutes + CookieInterval = 1, // 1 Day PingInterval = 1 * Time.Minute, // 1 Minute PingTimeout = 5 * Time.Minute, // 5 Minutes SummaryInterval = 100, // Same events within 100ms will be collapsed into single summary @@ -248,7 +250,11 @@ export interface Session { count: number; upgrade: BooleanFlag; upload: string; - server: string; +} + +export interface User { + id: string; + expiry: number; } export interface Envelope extends Metadata { diff --git a/packages/clarity-visualize/package.json b/packages/clarity-visualize/package.json index 46ab8603..68f1b404 100644 --- a/packages/clarity-visualize/package.json +++ b/packages/clarity-visualize/package.json @@ -1,6 +1,6 @@ { "name": "clarity-visualize", - "version": "0.6.20", + "version": "0.6.21", "description": "An analytics library that uses web page interactions to generate aggregated insights", "author": "Microsoft Corp.", "license": "MIT", @@ -27,7 +27,7 @@ "url": "https://github.com/Microsoft/clarity/issues" }, "dependencies": { - "clarity-decode": "^0.6.20" + "clarity-decode": "^0.6.21" }, "devDependencies": { "@rollup/plugin-commonjs": "^19.0.1", diff --git a/yarn.lock b/yarn.lock index 1eed1b38..8784fc1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1497,11 +1497,6 @@ chokidar@3.5.2: optionalDependencies: fsevents "~2.3.2" -chownr@^1.1.1: - version "1.1.4" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" - integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== - chownr@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" @@ -2299,13 +2294,6 @@ fs-extra@^9.1.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-minipass@^1.2.5: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" - integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== - dependencies: - minipass "^2.6.0" - fs-minipass@^2.0.0, fs-minipass@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" @@ -3558,14 +3546,6 @@ minipass-sized@^1.0.3: dependencies: minipass "^3.0.0" -minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" - integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - minipass@^3.0.0, minipass@^3.1.0, minipass@^3.1.1, minipass@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd" @@ -3573,13 +3553,6 @@ minipass@^3.0.0, minipass@^3.1.0, minipass@^3.1.1, minipass@^3.1.3: dependencies: yallist "^4.0.0" -minizlib@^1.2.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" - integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== - dependencies: - minipass "^2.9.0" - minizlib@^2.0.0, minizlib@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" @@ -3597,7 +3570,7 @@ mkdirp-infer-owner@^2.0.0: infer-owner "^1.0.4" mkdirp "^1.0.3" -mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3: +mkdirp@^0.5.1, mkdirp@^0.5.3: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -5199,23 +5172,10 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -tar@^4.4.12: - version "4.4.15" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.15.tgz#3caced4f39ebd46ddda4d6203d48493a919697f8" - integrity sha512-ItbufpujXkry7bHH9NpQyTXPbJ72iTlXgkBAYsAjDXk3Ds8t/3NfO5P4xZGy7u+sYuQUbimgzswX4uQIEeNVOA== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.8.6" - minizlib "^1.2.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.3" - -tar@^6.0.2, tar@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83" - integrity sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA== +tar@^4.4.12, tar@^6.0.2, tar@^6.1.0, tar@^6.1.7: + version "6.1.7" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.7.tgz#c566d1107d38b09e92983a68db5534fc7f6cab42" + integrity sha512-PBoRkOJU0X3lejJ8GaRCsobjXTgFofRDSPdSUhRSdlwJfifRlQBwGXitDItdGFu0/h0XDMCkig0RN1iT7DBxhA== dependencies: chownr "^2.0.0" fs-minipass "^2.0.0" @@ -5727,11 +5687,6 @@ y18n@^5.0.5: resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== -yallist@^3.0.0, yallist@^3.0.3: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" - integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== - yallist@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"