All files / src/web analytics.js

100% Statements 67/67
100% Branches 47/47
100% Functions 23/23
100% Lines 51/51

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138        104x 104x   2243x             70x 56x 56x 56x 56x 56x 64x 64x   55x   1x       104x       1x       2225x   104x     104x 26x       104x                   11x 11x 11x 3x           11x         165x     4795x             5457x 2049x 4795x 2049x               1588x 1588x     104x     19x 34x 34x   3x     104x 104x       111x 107x 106x   110x 20x 20x                       3x 171x   9x 9x   3x    
import { buildUrlFromParams, ParamTypes, parseLocationParams } from "../url-params.js";
import { parameterFor, Parameters, urlParamType } from "./parameters.js";
import { errorLabel } from "./analytics-filters.js";
 
const MaxErrorsNoted = 10;
const StackFrame = /(?:^\s*at |@).*?([^\s(@]+):(\d+):(\d+)\)?\s*$/;
 
const measurementId = () => import.meta.env.GA_MEASUREMENT_ID;
 
/**
 * `href` with each parameter put through its `analyticsFilter`, read and written as the page reads and writes its own
 * URL; with no parameters at all when they cannot be decoded.
 */
export function analyticsLocation(href) {
    if (!href) return href;
    const url = new URL(href);
    const page = url.origin + url.pathname;
    try {
        const params = parseLocationParams(url, urlParamType);
        for (const [key, value] of Object.entries(params)) {
            const filter = parameterFor(key)?.parameter.analyticsFilter;
            if (filter && value !== null) params[key] = Array.isArray(value) ? value.map(filter) : filter(value);
        }
        return buildUrlFromParams(page, params, urlParamType);
    } catch {
        return page;
    }
}
 
let silenced = false;
 
/** Sends nothing from here on: benchmark runs, many of them scripted, would swamp the counts. */
export function silenceAnalytics() {
    silenced = true;
}
 
/*global gtag*/
const injectedGtag = () => (!silenced && measurementId() && typeof gtag === "function" ? gtag : undefined);
 
const InstalledDisplayModes = ["standalone", "window-controls-overlay", "minimal-ui", "fullscreen"];
 
/** How the page is shown: the installed app's display mode, or "browser" for a tab. */
export const displayMode = () =>
    InstalledDisplayModes.find((mode) => window.matchMedia(`(display-mode: ${mode})`).matches) ??
    // Safari on iOS has no display-mode query for a page added to the home screen.
    (window.navigator.standalone ? "standalone" : "browser");
 
const pageLocations = () => ({
    page_location: analyticsLocation(window.location.href),
    page_referrer: analyticsLocation(document.referrer),
});
 
/**
 * Sends the page_view, telling Google Analytics where the page is as analyticsLocation does and how it is shown, and
 * notes the errors `page` reports and its being installed as an app.
 */
export function configureAnalytics(page = window) {
    injectedGtag()?.("config", measurementId(), { ...pageLocations(), display_mode: displayMode() });
    page.addEventListener("appinstalled", () => note("app_installed", {}));
    page.addEventListener("error", (event) =>
        noteError("window", event.error ?? event.message, {
            file: event.filename,
            line: event.lineno,
            column: event.colno,
        }),
    );
    page.addEventListener("unhandledrejection", (event) => noteError("promise", event.reason));
}
 
/** Moves the location later events report to the URL the page has just pushed, without a page_view. */
export function noteLocation() {
    injectedGtag()?.("config", measurementId(), { ...pageLocations(), send_page_view: false });
}
 
const logValue = (value) => (typeof value === "string" && /^$|[\s"=]/.test(value) ? JSON.stringify(value) : value);
 
/**
 * Sends `event`, leaving out the parameters that are undefined, and logs what it sends, or would send, as one line of
 * text.
 */
export function note(event, params) {
    const sent = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== undefined));
    injectedGtag()?.("event", event, sent);
    const pairs = Object.entries(sent).map(([key, value]) => ` ${key}=${logValue(value)}`);
    console.log(`event noted: ${event}${pairs.join("")}`);
}
 
/**
 * Notes `action` on a feature: the `data-feature` that `feature`, an element, is or is inside, or nothing without
 * one; or `feature` itself, for a feature with no element to declare it on.
 */
export function noteFeature(feature, action) {
    const name = typeof feature === "string" ? feature : feature?.closest("[data-feature]")?.dataset.feature;
    if (name) note("feature_used", { feature: name, action });
}
 
const fileName = (url) => url.split(/[?#]/)[0].split("/").pop();
 
function stackPlace(stack) {
    for (const line of String(stack ?? "").split("\n")) {
        const frame = line.match(StackFrame);
        if (frame) return `${fileName(frame[1])}:${frame[2]}:${frame[3]}`;
    }
    return undefined;
}
 
const errorsNoted = new WeakSet();
let errorCount = 0;
 
/** Notes an error once however many places see it (the loop rethrows what it notes), and only the first few. */
export function noteError(where, error, { file, line, column } = {}) {
    if (Object(error) === error) {
        if (errorsNoted.has(error)) return;
        errorsNoted.add(error);
    }
    if (errorCount >= MaxErrorsNoted) return;
    ++errorCount;
    note("js_error", {
        where,
        message: errorLabel(error?.message ?? error),
        file_line: file ? `${fileName(file)}:${line}:${column}` : stackPlace(error?.stack),
    });
}
 
/**
 * Notes how the machine started: every parameter declared `reportedAtStart` as it is now, and what the page `found`,
 * once `audio` resolves to how the sound came up.
 */
export async function noteMachineStart(settings, params, found, audio) {
    const reported = Object.entries(Parameters)
        .filter(([, parameter]) => parameter.reportedAtStart)
        .map(([name, { type }]) => {
            const value = settings.value(name) ?? params[name];
            return [name, type === ParamTypes.BOOL ? !!value : value];
        });
    note("machine_start", { ...Object.fromEntries(reported), ...found, audio: await audio });
}