1
0
Fork 0
firefox/toolkit/components/formautofill/shared/AutofillTelemetry.sys.mjs
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

430 lines
13 KiB
JavaScript

/* 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/. */
import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
const { FIELD_STATES } = FormAutofillUtils;
class AutofillTelemetryBase {
SUPPORTED_FIELDS = {};
EVENT_CATEGORY = null;
EVENT_OBJECT_FORM_INTERACTION = null;
#initFormEventExtra(value) {
let extra = {};
for (const field of Object.values(this.SUPPORTED_FIELDS)) {
extra[field] = value;
}
return extra;
}
#setFormEventExtra(extra, key, value) {
if (!this.SUPPORTED_FIELDS[key]) {
return;
}
extra[this.SUPPORTED_FIELDS[key]] = value;
}
recordFormDetected(flowId, fieldDetails) {
let extra = this.#initFormEventExtra("false");
let identified = new Set();
fieldDetails.forEach(detail => {
identified.add(detail.fieldName);
if (detail.reason == "autocomplete") {
this.#setFormEventExtra(extra, detail.fieldName, "true");
} else {
// confidence exists only when a field is identified by fathom.
let confidence =
detail.confidence > 0 ? Math.floor(100 * detail.confidence) / 100 : 0;
this.#setFormEventExtra(
extra,
detail.fieldName,
confidence ? confidence.toString() : "0"
);
}
});
this.recordFormEvent("detected", flowId, extra);
try {
this.recordIframeLayoutDetection(flowId, fieldDetails);
} catch {}
}
recordPopupShown(flowId, fieldDetails) {
const extra = { field_name: fieldDetails[0].fieldName };
this.recordFormEvent("popup_shown", flowId, extra);
}
setUpFormFilledExtra(fieldDetails, data) {
// Calculate values for telemetry
const extra = this.#initFormEventExtra("unavailable");
for (const fieldDetail of fieldDetails) {
// It is possible that we don't autofill a field because it is cross-origin.
// When that happens, the data will not include that element.
let { filledState, filledValue, isFilledOnFieldsUpdate } =
data.get(fieldDetail.elementId) ?? {};
switch (filledState) {
case FIELD_STATES.AUTO_FILLED:
filledState = isFilledOnFieldsUpdate
? "filled_on_fields_update"
: "filled";
break;
case FIELD_STATES.NORMAL:
default:
filledState =
fieldDetail.localName == "select" || filledValue?.length
? "user_filled"
: "not_filled";
break;
}
this.#setFormEventExtra(extra, fieldDetail.fieldName, filledState);
}
return extra;
}
recordFormFilled(flowId, fieldDetails, data) {
const extra = this.setUpFormFilledExtra(fieldDetails, data);
this.recordFormEvent("filled", flowId, extra);
}
recordFormFilledOnFieldsUpdate(flowId, fieldDetails, data) {
const extra = this.setUpFormFilledExtra(fieldDetails, data);
this.recordFormEvent("filled_on_fields_update", flowId, extra);
}
recordFilledModified(flowId, fieldDetails) {
const extra = { field_name: fieldDetails[0].fieldName };
this.recordFormEvent("filled_modified", flowId, extra);
}
recordFormSubmitted(flowId, fieldDetails, data) {
const extra = this.#initFormEventExtra("unavailable");
for (const fieldDetail of fieldDetails) {
let { filledState, filledValue } = data.get(fieldDetail.elementId) ?? {};
switch (filledState) {
case FIELD_STATES.AUTO_FILLED:
filledState = "autofilled";
break;
case FIELD_STATES.NORMAL:
default:
filledState =
fieldDetail.localName == "select" || filledValue?.length
? "user_filled"
: "not_filled";
break;
}
this.#setFormEventExtra(extra, fieldDetail.fieldName, filledState);
}
this.recordFormEvent("submitted", flowId, extra);
}
recordFormCleared(flowId, fieldDetails) {
const extra = { field_name: fieldDetails[0].fieldName };
// Note that when a form is cleared, we also record `filled_modified` events
// for all the fields that have been cleared.
this.recordFormEvent("cleared", flowId, extra);
}
recordFormEvent(_method, _flowId, _extra) {
throw new Error("Not implemented.");
}
recordFormInteractionEvent(method, flowId, fieldDetails, data) {
if (!this.EVENT_OBJECT_FORM_INTERACTION) {
return undefined;
}
switch (method) {
case "detected":
return this.recordFormDetected(flowId, fieldDetails);
case "popup_shown":
return this.recordPopupShown(flowId, fieldDetails);
case "filled":
return this.recordFormFilled(flowId, fieldDetails, data);
case "filled_on_fields_update":
return this.recordFormFilledOnFieldsUpdate(flowId, fieldDetails, data);
case "filled_modified":
return this.recordFilledModified(flowId, fieldDetails);
case "submitted":
return this.recordFormSubmitted(flowId, fieldDetails, data);
case "cleared":
return this.recordFormCleared(flowId, fieldDetails);
}
return undefined;
}
recordDoorhangerEvent(method, object, flowId) {
const eventName = `${method}_${object}`.replace(/(_[a-z])/g, c =>
c[1].toUpperCase()
);
Glean[this.EVENT_CATEGORY][eventName]?.record({ value: flowId });
}
recordManageEvent(method) {
const eventName =
method.replace(/(_[a-z])/g, c => c[1].toUpperCase()) + "Manage";
Glean[this.EVENT_CATEGORY][eventName]?.record();
}
recordAutofillProfileCount(_count) {
throw new Error("Not implemented.");
}
recordIframeLayoutDetection(flowId, fieldDetails) {
const fieldsInMainFrame = [];
const fieldsInIframe = [];
const fieldsInSandboxedIframe = [];
const fieldsInCrossOrignIframe = [];
const iframes = new Set();
for (const fieldDetail of fieldDetails) {
const bc = BrowsingContext.get(fieldDetail.browsingContextId);
if (bc.top == bc) {
fieldsInMainFrame.push(fieldDetail);
continue;
}
iframes.add(bc);
fieldsInIframe.push(fieldDetail);
if (bc.sandboxFlags != 0) {
fieldsInSandboxedIframe.push(fieldDetail);
}
if (!FormAutofillUtils.isBCSameOriginWithTop(bc)) {
fieldsInCrossOrignIframe.push(fieldDetail);
}
}
const extra = {
category: this.EVENT_CATEGORY,
flow_id: flowId,
iframe_count: iframes.size,
main_frame: fieldsInMainFrame.map(f => f.fieldName).toString(),
iframe: fieldsInIframe.map(f => f.fieldName).toString(),
cross_origin: fieldsInCrossOrignIframe.map(f => f.fieldName).toString(),
sandboxed: fieldsInSandboxedIframe.map(f => f.fieldName).toString(),
};
Glean.formautofill.iframeLayoutDetection.record(extra);
}
}
export class AddressTelemetry extends AutofillTelemetryBase {
EVENT_CATEGORY = "address";
EVENT_OBJECT_FORM_INTERACTION = "AddressForm";
EVENT_OBJECT_FORM_INTERACTION_EXT = "AddressFormExt";
// Fields that are recorded in `address_form` and `address_form_ext` telemetry
SUPPORTED_FIELDS = {
"street-address": "street_address",
"address-line1": "address_line1",
"address-line2": "address_line2",
"address-line3": "address_line3",
"address-level1": "address_level1",
"address-level2": "address_level2",
"postal-code": "postal_code",
country: "country",
name: "name",
"given-name": "given_name",
"additional-name": "additional_name",
"family-name": "family_name",
email: "email",
organization: "organization",
tel: "tel",
};
// Fields that are recorded in `address_form` event telemetry extra_keys
static SUPPORTED_FIELDS_IN_FORM = [
"street_address",
"address_line1",
"address_line2",
"address_line3",
"address_level2",
"address_level1",
"postal_code",
"country",
];
// Fields that are recorded in `address_form_ext` event telemetry extra_keys
static SUPPORTED_FIELDS_IN_FORM_EXT = [
"name",
"given_name",
"additional_name",
"family_name",
"email",
"organization",
"tel",
];
recordFormEvent(method, flowId, extra) {
let extExtra = {};
if (["detected", "filled", "submitted"].includes(method)) {
for (const [key, value] of Object.entries(extra)) {
if (AddressTelemetry.SUPPORTED_FIELDS_IN_FORM_EXT.includes(key)) {
extExtra[key] = value;
delete extra[key];
}
}
}
const eventMethod = method.replace(/(_[a-z])/g, c => c[1].toUpperCase());
Glean.address[eventMethod + this.EVENT_OBJECT_FORM_INTERACTION]?.record({
value: flowId,
...extra,
});
if (Object.keys(extExtra).length) {
Glean.address[
eventMethod + this.EVENT_OBJECT_FORM_INTERACTION_EXT
]?.record({ value: flowId, ...extExtra });
}
}
recordAutofillProfileCount(count) {
Glean.formautofillAddresses.autofillProfilesCount.set(count);
}
}
class CreditCardTelemetry extends AutofillTelemetryBase {
EVENT_CATEGORY = "creditcard";
EVENT_OBJECT_FORM_INTERACTION = "CcFormV2";
// Mapping of field name used in formautofill code to the field name
// used in the telemetry.
SUPPORTED_FIELDS = {
"cc-name": "cc_name",
"cc-number": "cc_number",
"cc-type": "cc_type",
"cc-exp": "cc_exp",
"cc-exp-month": "cc_exp_month",
"cc-exp-year": "cc_exp_year",
};
recordFormEvent(method, flowId, aExtra) {
// Don't modify the passed-in aExtra as it's reused.
const extra = Object.assign({ value: flowId }, aExtra);
const eventMethod = method.replace(/(_[a-z])/g, c => c[1].toUpperCase());
Glean.creditcard[eventMethod + this.EVENT_OBJECT_FORM_INTERACTION]?.record(
extra
);
}
recordFormDetected(flowId, fieldDetails) {
super.recordFormDetected(flowId, fieldDetails);
this.recordCcNumberFieldsCount(fieldDetails);
}
/**
* Collect the amount of consecutive cc number fields to help decide
* whether to support filling other field counts besides 1 and 4 fields
*/
recordCcNumberFieldsCount(fieldDetails) {
const recordCount = count => {
const label = "cc_number_fields_" + (count > 4 ? "other" : count);
Glean.creditcard.detectedCcNumberFieldsCount[label].add(1);
};
let consecutiveCcNumberCount = 0;
for (const { fieldName, reason } of fieldDetails) {
if (fieldName == "cc-number" && reason == "autocomplete") {
consecutiveCcNumberCount++;
} else if (consecutiveCcNumberCount) {
recordCount(consecutiveCcNumberCount);
consecutiveCcNumberCount = 0;
}
}
if (consecutiveCcNumberCount) {
recordCount(consecutiveCcNumberCount);
}
}
recordAutofillProfileCount(count) {
Glean.formautofillCreditcards.autofillProfilesCount.set(count);
}
}
export class AutofillTelemetry {
static #creditCardTelemetry = new CreditCardTelemetry();
static #addressTelemetry = new AddressTelemetry();
// const for `type` parameter used in the utility functions
static ADDRESS = "address";
static CREDIT_CARD = "creditcard";
static #getTelemetryByFieldDetail(fieldDetail) {
return FormAutofillUtils.isAddressField(fieldDetail.fieldName)
? this.#addressTelemetry
: this.#creditCardTelemetry;
}
static #getTelemetryByType(type) {
return type == AutofillTelemetry.CREDIT_CARD
? this.#creditCardTelemetry
: this.#addressTelemetry;
}
/**
* Utility functions for `doorhanger` event (defined in Events.yaml)
*
* Category: address or creditcard
* Event name: doorhanger
*/
static recordDoorhangerShown(type, object, flowId) {
const telemetry = this.#getTelemetryByType(type);
telemetry.recordDoorhangerEvent("show", object, flowId);
}
static recordDoorhangerClicked(type, method, object, flowId) {
const telemetry = this.#getTelemetryByType(type);
// We don't have `create` method in telemetry, we treat `create` as `save`
switch (method) {
case "create":
method = "save";
break;
case "open-pref":
method = "pref";
break;
case "learn-more":
method = "learn_more";
break;
}
telemetry.recordDoorhangerEvent(method, object, flowId);
}
/**
* Utility functions for form event (defined in Events.yaml)
*
* Category: address or creditcard
* Event name: cc_form_v2, or address_form
*/
static recordFormInteractionEvent(method, flowId, fieldDetails, data) {
const telemetry = this.#getTelemetryByFieldDetail(fieldDetails[0]);
telemetry.recordFormInteractionEvent(method, flowId, fieldDetails, data);
}
static recordManageEvent(type, method) {
const telemetry = this.#getTelemetryByType(type);
telemetry.recordManageEvent(method);
}
static recordAutofillProfileCount(type, count) {
const telemetry = this.#getTelemetryByType(type);
telemetry.recordAutofillProfileCount(count);
}
static recordFormSubmissionHeuristicCount(label) {
Glean.formautofill.formSubmissionHeuristic[label].add(1);
}
}