/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ let schemaRegistered = false; function ensureSchemaRegistered() { if (schemaRegistered) { return; } schemaRegistered = true; ChromeUtils.registerMarkerSchema({ name: "PageExtractor", tooltipLabel: "{marker.data.phaseLabel}", tableLabel: "{marker.data.process}: {marker.data.phaseLabel}", chartLabel: "{marker.data.phaseLabel}", display: ["marker-chart", "marker-table"], colorField: "color", data: [ { key: "process", label: "Process", format: "string" }, { key: "phaseLabel", label: "Description", format: "string", searchable: true, }, { key: "phase", label: "Phase", format: "string", searchable: true }, { key: "flowId", label: "Flow ID", format: "string", searchable: true, }, // The `PageExtractor #` track this marker is drawn on. On the // payload as well as in the track name, so that two calls sharing a // flow are still tellable apart from the marker table. { key: "traceId", label: "Trace", format: "integer" }, { key: "strategy", label: "Strategy", format: "string", searchable: true, }, { key: "siteStrategy", label: "Site strategy", format: "string", searchable: true, }, { key: "status", label: "Status", format: "string", searchable: true }, { key: "options", label: "Selected options", format: "string" }, // Its own field rather than part of `options`, because "url" is what // makes the profiler strip it from a profile shared with URLs // excluded. The "string" format of `options` is explicitly not // sanitized. { key: "sourceUrl", label: "Source URL", format: "url" }, { key: "textLength", label: "Text length", format: "integer" }, { key: "linkCount", label: "Links", format: "integer" }, { key: "canvasCount", label: "Canvases", format: "integer" }, { key: "errorName", label: "Error", format: "string", searchable: true, }, { key: "color", hidden: true }, ], }); } /** * Formats only the options the caller passed to getText(), so the marker * shows what was selected, not defaults it never applied. * * `sourceUrl` is excluded: it goes on its own "url"-format field, which the * profiler sanitizes. Anything added here lands in a "string" field that it * does not, so keep URLs and paths out. * * @param {Record} options */ function formatOptions(options) { return Object.entries(options) .filter(([key, value]) => value !== undefined && key !== "sourceUrl") .map(([key, value]) => `${key}=${value}`) .join(", "); } /** * One phase a PageExtractor call can go through. `name` is what lands in * the marker's `phase` field and in `page_extractor.phase`, for correlating * with code; `label` is the plain-language description a non-developer * reading a shared profile sees. * * @typedef {object} PageExtractorPhase * @property {string} name - The phase as recorded, e.g. "dom-extract". * @property {string} label - The description shown in a profile. */ /** * Identifies one trace: a single PageExtractor call and every phase nested * under it. * * @typedef {object} TraceId * @property {number} id - Numbers the trace within this session, and names * the `PageExtractor #` profiler track its markers are drawn on. * @property {string} flowId - The flow the trace belongs to, recorded as * `page_extractor.phase`'s `flow_id`. One flow can span several traces, * so this is the coarser of the two. */ // Counts traces so each gets its own profiler track: concurrent calls don't // interleave on one, and a nested phase shares its call's track. let nextId = 1; const DEFAULT_MARKER_COLOR = "blue"; // Statuses other than "success" or "error" (e.g. "unavailable", // "document-hidden", "empty") are handled outcomes, not thrown exceptions. const HANDLED_OUTCOME_MARKER_COLOR = "yellow"; const ERROR_MARKER_COLOR = "red"; let submissionScheduled = false; /** * The page-extractor ping is submitted on `idle-daily`, from the parent * process only. Scheduled on the first recorded event rather than at module * load, so a session that never extracts a page never submits, and content * processes (whose events reach the parent through FOG) never try to. * * Events already recorded outlive a session in Glean's database, so a * session that ends before `idle-daily` fires loses nothing. */ function scheduleSubmission() { if ( submissionScheduled || Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT ) { return; } submissionScheduled = true; Services.obs.addObserver( () => GleanPings.pageExtractor.submit(), "idle-daily" ); } // Maps this.#data's camelCase field names to page_extractor.phase's // extra_keys in metrics.yaml. Keep these two lists in sync. const GLEAN_EXTRA_KEYS = { process: "process", phase: "phase", strategy: "strategy", siteStrategy: "site_strategy", status: "status", errorName: "error_name", textLength: "text_length", linkCount: "link_count", canvasCount: "canvas_count", }; // Page-derived counts, bucketed before recording so an exact value can't // fingerprint the page (data review). duration_ms measures our own work, // so it stays exact. The profiler marker keeps exact values for everything. const GLEAN_BUCKETED_KEYS = new Set([ "text_length", "link_count", "canvas_count", ]); /** * Rounds a count down to a power of two; 0 stays 0. Counts are string and * array lengths, so they fit the 32-bit range Math.clz32 works on. * * @param {number} count * @returns {number} */ function bucketToPowerOfTwo(count) { if (count <= 0) { return 0; } return 2 ** (31 - Math.clz32(count)); } /** * Accumulates data for one PageExtractor instrumentation event. `finish()` * always records a `page_extractor.phase` Glean event, and a profiler * marker only while profiling; both are keyed by a `TraceId` so parent- and * content-process records describe the same request. * * `PageExtractorEvent.trace()` is the entry point for the common case of * measuring one task from start to finish. The constructor and `finish()` * are also exposed directly for the rarer case of an event whose outcome * races something other than a single task (e.g. navigateEvent in * PageExtractorParent.getHeadlessExtractor, settled from three different * callbacks). */ export class PageExtractorEvent { /** * Every phase this instrumentation knows about, kept in one place so * adding or renaming one only touches this file. * * Callers name a phase through this object rather than by string, so a * misspelling is `undefined` and throws below, rather than silently * mislabelling a marker and fragmenting `page_extractor.phase` under a * slice that doesn't exist. */ static Phase = Object.freeze({ headlessExtractor: { name: "headless-extractor", label: "Load page in the background", }, headlessNavigate: { name: "headless-navigate", label: "Navigate to page", }, waitForReady: { name: "wait-for-ready", label: "Wait for page to finish loading", }, getPageMetadata: { name: "get-page-metadata", label: "Read page details" }, getText: { name: "get-text", label: "Extract page text" }, pdfExtract: { name: "pdf-extract", label: "Read PDF text" }, readerParse: { name: "reader-parse", label: "Simplify page (Reader Mode)", }, readerOutputParse: { name: "reader-output-parse", label: "Process simplified page", }, domExtract: { name: "dom-extract", label: "Scan page content" }, canvasCapture: { name: "canvas-capture", label: "Capture page images" }, youtubeExtract: { name: "youtube-extract", label: "Read video transcript", }, }); static #phases = new Set(Object.values(this.Phase)); #data; #innerWindowId; #startTime; #options; #traceId; #finished = false; /** * Starts a new trace, taking the next `PageExtractor #` profiler * track for it. * * @param {string} [flowId] - The flow this trace runs as part of, when it * belongs to a larger one that spans several traces. Mints a flow of * this trace's own when omitted. * @returns {TraceId} */ static nextTraceId(flowId = crypto.randomUUID()) { return Object.freeze({ id: nextId++, flowId }); } /** * @param {PageExtractorPhase} phase - A `PageExtractorEvent.Phase` member. * @param {object} data * @param {TraceId} [data.traceId] - The trace to record this phase under. * Starts a trace of its own when omitted. * @param {string} [data.process] * @param {string} [data.strategy] * @param {number} [data.innerWindowId] * @param {Record} [data.options] */ constructor(phase, data) { if (!PageExtractorEvent.#phases.has(phase)) { throw new Error( `Not a PageExtractorEvent.Phase member: ${JSON.stringify(phase)}` ); } // Timed from construction, not from trace() below: some events (e.g. // navigateEvent in PageExtractorParent.getHeadlessExtractor) are // finished directly, racing multiple outcomes, and never go through // trace() at all. Construction is the one point every event passes // through. this.#startTime = ChromeUtils.now(); this.#innerWindowId = data.innerWindowId; this.#options = data.options; this.#traceId = data.traceId ?? PageExtractorEvent.nextTraceId(); this.#data = { type: "PageExtractor", process: data.process, phase: phase.name, phaseLabel: phase.label, flowId: this.#traceId.flowId, traceId: this.#traceId.id, }; if (data.strategy) { this.#data.strategy = data.strategy; } } /** * The trace this event belongs to. Pass it as a nested * PageExtractorEvent's `traceId`, over IPC included, so that phase lands * on the same profiler track and correlates with this one in * `page_extractor.phase`. * * @returns {TraceId} */ get traceId() { return this.#traceId; } addData(data) { for (const [key, value] of Object.entries(data)) { if (value !== undefined && value !== null) { this.#data[key] = value; } } } // Idempotent so racing outcomes (e.g. navigation vs. timeout) can each // call finish() unconditionally; the first call wins. finish(data = {}) { if (this.#finished) { return; } this.#finished = true; this.addData(data); const gleanPayload = { flow_id: this.#traceId.flowId, }; for (const [dataKey, gleanKey] of Object.entries(GLEAN_EXTRA_KEYS)) { const value = this.#data[dataKey]; gleanPayload[gleanKey] = value !== undefined && GLEAN_BUCKETED_KEYS.has(gleanKey) ? bucketToPowerOfTwo(value) : value; } gleanPayload.duration_ms = Math.round(ChromeUtils.now() - this.#startTime); // Recorded before the profiler-active check: Glean is the permanent // record, the profiler marker isn't. Glean.pageExtractor.phase.record(gleanPayload); scheduleSubmission(); if (!Services.profiler.IsActive()) { return; } ensureSchemaRegistered(); if (this.#data.status === "success") { this.#data.color = DEFAULT_MARKER_COLOR; } else if (this.#data.status === "error") { this.#data.color = ERROR_MARKER_COLOR; } else { this.#data.color = HANDLED_OUTCOME_MARKER_COLOR; } const formatted = this.#options ? formatOptions(this.#options) : ""; if (formatted) { this.#data.options = formatted; } if (this.#options?.sourceUrl) { this.#data.sourceUrl = this.#options.sourceUrl; } ChromeUtils.addProfilerMarker( `PageExtractor #${this.#traceId.id}`, { category: "JavaScript", innerWindowId: this.#innerWindowId, startTime: this.#startTime, }, this.#data ); } /** * Runs `task`, finishing with "success" on resolve or "error" (and * rethrowing) on throw. `task` can call `finish()` itself first for a * more specific status (e.g. "unavailable"); since finish() is * idempotent, the status set here is then a no-op. * * Private: only `trace()` below calls this, immediately after * construction, so that constructing an event and timing a task are * never two separate steps a caller can pull apart. * * @template T * @param {() => Promise | T} task * @returns {Promise} */ async #run(task) { try { const result = await task(); this.finish({ status: "success" }); return result; } catch (error) { // Optional chaining because JS permits throwing anything: a task that // rejects with null would otherwise throw a TypeError here, replacing // the original rejection and leaving this event never finished. this.finish({ status: "error", errorName: error?.name }); throw error; } } /** * Constructs a PageExtractorEvent for `phase` and immediately measures * `task` with it, passing the event to `task` so it can call * `addData()`/`finish()` for a more specific status before `task` * resolves. This is the usual way to create a PageExtractorEvent: since * construction and the start of the measured work happen in the same * call, there's no separate step where the clock could be started later. * * @template T * @param {PageExtractorPhase} phase - A `PageExtractorEvent.Phase` member. * @param {Record} data * @param {(event: PageExtractorEvent) => Promise | T} task * @returns {Promise} */ static trace(phase, data, task) { const event = new PageExtractorEvent(phase, data); return event.#run(() => task(event)); } }