diff options
Diffstat (limited to 'toolkit/components/formautofill/AutofillTelemetry.sys.mjs')
-rw-r--r-- | toolkit/components/formautofill/AutofillTelemetry.sys.mjs | 629 |
1 files changed, 629 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/AutofillTelemetry.sys.mjs b/toolkit/components/formautofill/AutofillTelemetry.sys.mjs new file mode 100644 index 0000000000..93aa99a4b8 --- /dev/null +++ b/toolkit/components/formautofill/AutofillTelemetry.sys.mjs @@ -0,0 +1,629 @@ +/* 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 ( + section.handler.getFilledStateByElement(element) == + 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", + }; + + recordLegacyFormEvent(method, flowId, extra = null) { + Services.telemetry.recordEvent( + this.EVENT_CATEGORY, + method, + "cc_form", + flowId, + extra + ); + } + + recordGleanFormEvent(eventName, flowId, extra) { + extra.flow_id = flowId; + Glean.formautofillCreditcards[eventName].record(extra); + } + + recordFormDetected(section) { + super.recordFormDetected(section); + + let identified = new Set(); + section.fieldDetails.forEach(detail => { + identified.add(detail.fieldName); + }); + let extra = { + cc_name_found: identified.has("cc-name") ? "true" : "false", + cc_number_found: identified.has("cc-number") ? "true" : "false", + cc_exp_found: + identified.has("cc-exp") || + (identified.has("cc-exp-month") && identified.has("cc-exp-year")) + ? "true" + : "false", + }; + + this.recordLegacyFormEvent("detected", section.flowId, extra); + } + + recordPopupShown(section, fieldName) { + super.recordPopupShown(section, fieldName); + + this.recordLegacyFormEvent("popup_shown", section.flowId); + } + + recordFormFilled(section, profile) { + super.recordFormFilled(section, profile); + // Calculate values for telemetry + let extra = { + cc_name: "unavailable", + cc_number: "unavailable", + cc_exp: "unavailable", + }; + + for (let fieldDetail of section.fieldDetails) { + let element = fieldDetail.element; + let state = profile[fieldDetail.fieldName] ? "filled" : "not_filled"; + if ( + section.handler.getFilledStateByElement(element) == + FIELD_STATES.NORMAL && + (HTMLSelectElement.isInstance(element) || + (HTMLInputElement.isInstance(element) && element.value.length)) + ) { + state = "user_filled"; + } + switch (fieldDetail.fieldName) { + case "cc-name": + extra.cc_name = state; + break; + case "cc-number": + extra.cc_number = state; + break; + case "cc-exp": + case "cc-exp-month": + case "cc-exp-year": + extra.cc_exp = state; + break; + } + } + + this.recordLegacyFormEvent("filled", section.flowId, extra); + } + + recordFilledModified(section, fieldName) { + super.recordFilledModified(section, fieldName); + + let extra = { field_name: fieldName }; + this.recordLegacyFormEvent("filled_modified", section.flowId, extra); + } + + /** + * Called when a credit card form is submitted + * + * @param {object} section Section that produces this record + * @param {object} record Credit card record filled in the form. + * @param {Array<HTMLForm>} form Form that contains the section + */ + recordFormSubmitted(section, record, form) { + super.recordFormSubmitted(section, record, form); + + // For legacy cc_form event telemetry + let extra = { + fields_not_auto: "0", + fields_auto: "0", + fields_modified: "0", + }; + + if (record.guid !== null) { + let totalCount = form.elements.length; + let autofilledCount = Object.keys(record.record).length; + let unmodifiedCount = record.untouchedFields.length; + + extra.fields_not_auto = (totalCount - autofilledCount).toString(); + extra.fields_auto = autofilledCount.toString(); + extra.fields_modified = (autofilledCount - unmodifiedCount).toString(); + } else { + // If the `guid` is null, we're filling a new form. + // In that case, all not-null fields are manually filled. + extra.fields_not_auto = Array.from(form.elements) + .filter(element => !!element.value?.trim().length) + .length.toString(); + } + + this.recordLegacyFormEvent("submitted", section.flowId, 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, 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); + } +} |