clarity-js
Version:
An analytics library that uses web page interactions to generate aggregated insights
182 lines (162 loc) • 8.06 kB
text/typescript
import { AsyncTask, Priority, RequestIdleCallbackDeadline, RequestIdleCallbackOptions, Task, Timer } from "@clarity-types/core";
import { TaskFunction, TaskResolve, Tasks } from "@clarity-types/core";
import { Code, Metric, Setting, Severity } from "@clarity-types/data";
import * as metadata from "@src/data/metadata";
import * as metric from "@src/data/metric";
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;
let tracker: Tasks = {};
let queuedTasks: AsyncTask[] = [];
let activeTask: AsyncTask = null;
let pauseTask: Promise<void> = null;
let resumeResolve: TaskResolve = null;
export function pause(): void {
if (pauseTask === null) {
pauseTask = new Promise<void>((resolve: TaskResolve): void => {
resumeResolve = resolve;
});
}
}
export function resume(): void {
if (pauseTask) {
resumeResolve();
pauseTask = null;
if (activeTask === null) { run(); }
}
}
export function reset(): void {
tracker = {};
queuedTasks = [];
activeTask = null;
pauseTask = null;
}
export async function schedule(task: TaskFunction, priority: Priority = Priority.Normal): Promise<void> {
// If this task is already scheduled, skip it
for (let q of queuedTasks) {
if (q.task === task) {
return;
}
}
let promise = new Promise<void>((resolve: TaskResolve): void => {
let insert = priority === Priority.High ? "unshift" : "push";
// Queue this task for asynchronous execution later
// We also store a unique page identifier (id) along with the task to ensure
// ensure that we do not accidentally execute this task in context of a different page
queuedTasks[insert]({ task, resolve, id: metadata.id() });
});
// If there is no active task running, and Clarity is not in pause state,
// invoke the first task in the queue synchronously. This ensures that we don't yield the thread during unload event
if (activeTask === null && pauseTask === null) { run(); }
return promise;
}
function run(): void {
let entry = queuedTasks.shift();
if (entry) {
activeTask = entry;
entry.task().then((): void => {
// Bail out if the context in which this task was operating is different from the current page
// An example scenario where task could span across pages is Single Page Applications (SPA)
// A task that started on page #1, but completes on page #2
if (entry.id !== metadata.id()) { return; }
entry.resolve();
activeTask = null; // Reset active task back to null now that the promise is resolved
run();
}).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; }
if (error) { internal.log(Code.RunTask, Severity.Warning, error.name, error.message, error.stack); }
activeTask = null;
run();
});
}
}
export function state(timer: Timer): Task {
let id = key(timer);
if (id in tracker) {
let elapsed = performance.now() - tracker[id].start;
return (elapsed > tracker[id].yield) ? Task.Wait : Task.Run;
}
// If this task is no longer being tracked, send stop message to the caller
return Task.Stop;
}
export function start(timer: Timer): void {
tracker[key(timer)] = { start: performance.now(), calls: 0, yield: Setting.LongTask };
}
function restart(timer: Timer): void {
let id = key(timer);
if (tracker && tracker[id]) {
let c = tracker[id].calls;
let y = tracker[id].yield;
start(timer);
tracker[id].calls = c + 1;
tracker[id].yield = y;
}
}
export function stop(timer: Timer): void {
let end = performance.now();
let id = key(timer);
let duration = end - tracker[id].start;
metric.sum(timer.cost, duration);
metric.count(Metric.InvokeCount);
// For the first execution, which is synchronous, time is automatically counted towards TotalDuration.
// However, for subsequent asynchronous runs, we need to manually update TotalDuration metric.
if (tracker[id].calls > 0) { metric.sum(Metric.TotalCost, duration); }
}
export async function suspend(timer: Timer): Promise<Task> {
// Suspend and yield the thread only if the task is still being tracked
// It's possible that Clarity is wrapping up instrumentation on a page and we are still in the middle of an async task.
// In that case, we do not wish to continue yielding thread.
// Instead, we will turn async task into a sync task and maximize our chances of getting some data back.
let id = key(timer);
if (id in tracker) {
stop(timer);
// some customer polyfills for requestIdleCallback return null
tracker[id].yield = (await wait())?.timeRemaining() || Setting.LongTask;
restart(timer);
}
// After we are done with suspending task, ensure that we are still operating in the right context
// If the task is still being tracked, continue running the task, otherwise ask caller to stop execution
return id in tracker ? Task.Run : Task.Stop;
}
function key(timer: Timer): string {
return timer.id + "." + timer.cost;
}
async function wait(): Promise<RequestIdleCallbackDeadline> {
if (pauseTask) { await pauseTask; }
return new Promise<RequestIdleCallbackDeadline>((resolve: (deadline: RequestIdleCallbackDeadline) => void): void => {
requestIdleCallback(resolve, { timeout: idleTimeout });
});
}
// Use native implementation of requestIdleCallback if it exists.
// Otherwise, fall back to a custom implementation using requestAnimationFrame & MessageChannel.
// While it's not possible to build a perfect polyfill given the nature of this API, the following code attempts to get close.
// Background context: requestAnimationFrame invokes the js code right before: style, layout and paint computation within the frame.
// This means, that any code that runs as part of requestAnimationFrame will by default be blocking in nature. Not what we want.
// For non-blocking behavior, We need to know when browser has finished painting. This can be accomplished in two different ways (hacks):
// (1) Use MessageChannel to pass the message, and browser will receive the message right after paint event has occured.
// (2) Use setTimeout call within requestAnimationFrame. This also works, but there's a risk that browser may throttle setTimeout calls.
// Given this information, we are currently using (1) from above. More information on (2) as well as some additional context is below:
// https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Performance_best_practices_for_Firefox_fe_engineers
function requestIdleCallbackPolyfill(callback: (deadline: RequestIdleCallbackDeadline) => void, options: RequestIdleCallbackOptions): void {
const startTime = performance.now();
const channel = new MessageChannel();
const incoming = channel.port1;
const outgoing = channel.port2;
incoming.onmessage = (event: MessageEvent): void => {
let currentTime = performance.now();
let elapsed = currentTime - startTime;
let duration = currentTime - event.data;
if (duration > Setting.LongTask && elapsed < options.timeout) {
requestAnimationFrame((): void => { outgoing.postMessage(currentTime); });
} else {
let didTimeout = elapsed > options.timeout;
callback({
didTimeout,
timeRemaining: (): number => didTimeout ? Setting.LongTask : Math.max(0, Setting.LongTask - duration)
});
}
};
requestAnimationFrame((): void => { outgoing.postMessage(performance.now()); });
}
let requestIdleCallback = window["requestIdleCallback"] || requestIdleCallbackPolyfill;