gecko-dev/toolkit/components/formautofill/shared/AutofillTelemetry.sys.mjs
Neil Deakin a7d450b343 Bug 1849122, set the autofill state directly on the input and select elements rather than using windowUtils and a separate hash that can be out of sync with each other, r=credential-management-reviewers,dimi
The existing autofill state is set on the form element using nsIDOMWindowUtils::AddManuallyManagedState, but a separate map #filledStateByElement within FormAutofillHandler.sys.mjs is used to keep track of which elements are filled in. However, this relies on input events that don't fire when form elements are hidden. In addition, when a page modifies the form field value directly, the preview state can be modified but the #filledStateByElement map is not.

Instead, remove the extra map, and just use the form field's autofill state directly. The form field will take responsibility for removing the preview state when its value changes.

This behaviour applies to single field input elements and select elements.

Differential Revision: https://phabricator.services.mozilla.com/D211000
2024-05-28 23:40:00 +00:00

511 lines
15 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";
import { FormAutofillCreditCardSection } from "resource://gre/modules/shared/FormAutofillSection.sys.mjs";
const { FIELD_STATES } = FormAutofillUtils;
class AutofillTelemetryBase {
SUPPORTED_FIELDS = {};
EVENT_CATEGORY = null;
EVENT_OBJECT_FORM_INTERACTION = null;
SCALAR_DETECTED_SECTION_COUNT = null;
SCALAR_SUBMITTED_SECTION_COUNT = null;
HISTOGRAM_NUM_USES = null;
HISTOGRAM_PROFILE_NUM_USES = null;
HISTOGRAM_PROFILE_NUM_USES_KEY = 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;
}
/**
* Building the extra keys object that is included in the Legacy Telemetry event `cc_form_v2`
* or `address_form` event and the Glean event `cc_form`, and `address_form`.
* It indicates the detected credit card or address fields and which method (autocomplete property, regular expression heuristics or fathom) identified them.
*
* @param {object} section Using section.fieldDetails to extract which fields were identified and how
* @param {string} undetected Default value when a field is not detected: 'undetected' (Glean) and 'false' in (Legacy)
* @param {string} autocomplete Value when a field is identified with autocomplete property: 'autocomplete' (Glean), 'true' (Legacy)
* @param {string} regexp Value when a field is identified with regex expression heuristics: 'regexp' (Glean), '0' (Legacy)
* @param {boolean} includeMultiPart Include multi part data or not
* @returns {object} Extra keys to include in the form event
*/
#buildFormDetectedEventExtra(
section,
undetected,
autocomplete,
regexp,
includeMultiPart
) {
let extra = this.#initFormEventExtra(undetected);
let identified = new Set();
section.fieldDetails.forEach(detail => {
identified.add(detail.fieldName);
if (detail.reason == "autocomplete") {
this.#setFormEventExtra(extra, detail.fieldName, autocomplete);
} 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() : regexp
);
}
if (
detail.fieldName === "cc-number" &&
this.SUPPORTED_FIELDS[detail.fieldName] &&
includeMultiPart
) {
extra.cc_number_multi_parts = detail.part ?? 1;
}
});
return extra;
}
recordFormDetected(section) {
this.recordFormEvent(
"detected",
section.flowId,
this.#buildFormDetectedEventExtra(section, "false", "true", "0", false)
);
this.recordGleanFormEvent(
"formDetected",
section.flowId,
this.#buildFormDetectedEventExtra(
section,
"undetected",
"autocomplete",
"regexp",
true
)
);
}
recordPopupShown(section, fieldName) {
const extra = { field_name: fieldName };
this.recordFormEvent("popup_shown", section.flowId, extra);
this.recordGleanFormEvent("formPopupShown", section.flowId, extra);
}
recordFormFilled(section, profile) {
// Calculate values for telemetry
let extra = this.#initFormEventExtra("unavailable");
for (let fieldDetail of section.fieldDetails) {
let element = fieldDetail.element;
let state = profile[fieldDetail.fieldName] ? "filled" : "not_filled";
if (
element.autofillState == FIELD_STATES.NORMAL &&
(HTMLSelectElement.isInstance(element) ||
(HTMLInputElement.isInstance(element) && element.value.length))
) {
state = "user_filled";
}
this.#setFormEventExtra(extra, fieldDetail.fieldName, state);
}
this.recordFormEvent("filled", section.flowId, extra);
this.recordGleanFormEvent("formFilled", section.flowId, extra);
}
recordFilledModified(section, fieldName) {
const extra = { field_name: fieldName };
this.recordFormEvent("filled_modified", section.flowId, extra);
this.recordGleanFormEvent("formFilledModified", section.flowId, extra);
}
recordFormSubmitted(section, record, _form) {
let extra = this.#initFormEventExtra("unavailable");
if (record.guid !== null) {
// If the `guid` is not null, it means we're editing an existing record.
// In that case, all fields in the record are autofilled, and fields in
// `untouchedFields` are unmodified.
for (const [fieldName, value] of Object.entries(record.record)) {
if (record.untouchedFields?.includes(fieldName)) {
this.#setFormEventExtra(extra, fieldName, "autofilled");
} else if (value) {
this.#setFormEventExtra(extra, fieldName, "user_filled");
} else {
this.#setFormEventExtra(extra, fieldName, "not_filled");
}
}
} else {
Object.keys(record.record).forEach(fieldName =>
this.#setFormEventExtra(extra, fieldName, "user_filled")
);
}
this.recordFormEvent("submitted", section.flowId, extra);
this.recordGleanFormEvent("formSubmitted", section.flowId, extra);
}
recordFormCleared(section, fieldName) {
const extra = { field_name: 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", section.flowId, extra);
this.recordGleanFormEvent("formCleared", section.flowId, extra);
}
recordFormEvent(method, flowId, extra) {
Services.telemetry.recordEvent(
this.EVENT_CATEGORY,
method,
this.EVENT_OBJECT_FORM_INTERACTION,
flowId,
extra
);
}
recordGleanFormEvent(_eventName, _flowId, _extra) {
throw new Error("Not implemented.");
}
recordFormInteractionEvent(
method,
section,
{ fieldName, profile, record, form } = {}
) {
if (!this.EVENT_OBJECT_FORM_INTERACTION) {
return undefined;
}
switch (method) {
case "detected":
return this.recordFormDetected(section);
case "popup_shown":
return this.recordPopupShown(section, fieldName);
case "filled":
return this.recordFormFilled(section, profile);
case "filled_modified":
return this.recordFilledModified(section, fieldName);
case "submitted":
return this.recordFormSubmitted(section, record, form);
case "cleared":
return this.recordFormCleared(section, fieldName);
}
return undefined;
}
recordDoorhangerEvent(method, object, flowId) {
Services.telemetry.recordEvent(this.EVENT_CATEGORY, method, object, flowId);
}
recordManageEvent(method) {
Services.telemetry.recordEvent(this.EVENT_CATEGORY, method, "manage");
}
recordAutofillProfileCount(_count) {
throw new Error("Not implemented.");
}
recordDetectedSectionCount() {
if (!this.SCALAR_DETECTED_SECTION_COUNT) {
return;
}
Services.telemetry.scalarAdd(this.SCALAR_DETECTED_SECTION_COUNT, 1);
}
recordSubmittedSectionCount(count) {
if (!this.SCALAR_SUBMITTED_SECTION_COUNT || !count) {
return;
}
Services.telemetry.scalarAdd(this.SCALAR_SUBMITTED_SECTION_COUNT, count);
}
recordNumberOfUse(records) {
let histogram = Services.telemetry.getKeyedHistogramById(
this.HISTOGRAM_PROFILE_NUM_USES
);
histogram.clear();
for (let record of records) {
histogram.add(this.HISTOGRAM_PROFILE_NUM_USES_KEY, record.timesUsed);
}
}
}
export class AddressTelemetry extends AutofillTelemetryBase {
EVENT_CATEGORY = "address";
EVENT_OBJECT_FORM_INTERACTION = "address_form";
EVENT_OBJECT_FORM_INTERACTION_EXT = "address_form_ext";
SCALAR_DETECTED_SECTION_COUNT =
"formautofill.addresses.detected_sections_count";
SCALAR_SUBMITTED_SECTION_COUNT =
"formautofill.addresses.submitted_sections_count";
SCALAR_AUTOFILL_PROFILE_COUNT =
"formautofill.addresses.autofill_profiles_count";
HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES";
HISTOGRAM_PROFILE_NUM_USES_KEY = "address";
// Fields that are record 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 record 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 record in `address_form_ext` event telemetry extra_keys
static SUPPORTED_FIELDS_IN_FORM_EXT = [
"name",
"given_name",
"additional_name",
"family_name",
"email",
"organization",
"tel",
];
recordGleanFormEvent(_eventName, _flowId, _extra) {
// To be implemented when migrating the legacy event address.address_form to Glean
}
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];
}
}
}
Services.telemetry.recordEvent(
this.EVENT_CATEGORY,
method,
this.EVENT_OBJECT_FORM_INTERACTION,
flowId,
extra
);
if (Object.keys(extExtra).length) {
Services.telemetry.recordEvent(
this.EVENT_CATEGORY,
method,
this.EVENT_OBJECT_FORM_INTERACTION_EXT,
flowId,
extExtra
);
}
}
recordAutofillProfileCount(count) {
Services.telemetry.scalarSet(this.SCALAR_AUTOFILL_PROFILE_COUNT, count);
}
}
class CreditCardTelemetry extends AutofillTelemetryBase {
EVENT_CATEGORY = "creditcard";
EVENT_OBJECT_FORM_INTERACTION = "cc_form_v2";
SCALAR_DETECTED_SECTION_COUNT =
"formautofill.creditCards.detected_sections_count";
SCALAR_SUBMITTED_SECTION_COUNT =
"formautofill.creditCards.submitted_sections_count";
HISTOGRAM_NUM_USES = "CREDITCARD_NUM_USES";
HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES";
HISTOGRAM_PROFILE_NUM_USES_KEY = "credit_card";
// 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",
};
recordGleanFormEvent(eventName, flowId, extra) {
extra.flow_id = flowId;
Glean.formautofillCreditcards[eventName].record(extra);
}
recordNumberOfUse(records) {
super.recordNumberOfUse(records);
if (!this.HISTOGRAM_NUM_USES) {
return;
}
let histogram = Services.telemetry.getHistogramById(
this.HISTOGRAM_NUM_USES
);
histogram.clear();
for (let record of records) {
histogram.add(record.timesUsed);
}
}
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 #getTelemetryBySection(section) {
return section instanceof FormAutofillCreditCardSection
? this.#creditCardTelemetry
: this.#addressTelemetry;
}
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,
section,
{ fieldName, profile, record, form } = {}
) {
const telemetry = this.#getTelemetryBySection(section);
telemetry.recordFormInteractionEvent(method, section, {
fieldName,
profile,
record,
form,
});
}
/**
* Utility functions for submitted section count scalar (defined in Scalars.yaml)
*
* Category: formautofill.creditCards or formautofill.addresses
* Scalar name: submitted_sections_count
*/
static recordDetectedSectionCount(section) {
const telemetry = this.#getTelemetryBySection(section);
telemetry.recordDetectedSectionCount();
}
static recordSubmittedSectionCount(type, count) {
const telemetry = this.#getTelemetryByType(type);
telemetry.recordSubmittedSectionCount(count);
}
static recordManageEvent(type, method) {
const telemetry = this.#getTelemetryByType(type);
telemetry.recordManageEvent(method);
}
static recordAutofillProfileCount(type, count) {
const telemetry = this.#getTelemetryByType(type);
telemetry.recordAutofillProfileCount(count);
}
/**
* Utility functions for address/credit card number of use
*/
static recordNumberOfUse(type, records) {
const telemetry = this.#getTelemetryByType(type);
telemetry.recordNumberOfUse(records);
}
static recordFormSubmissionHeuristicCount(label) {
Glean.formautofill.formSubmissionHeuristic[label].add(1);
}
}