Fix errors along the autofill form submission path where values were not safely extracted from the form data and it was leading to errors, which subsequently prevented the "save card" prompt from showing up.
This patch fixes that issue by safely extracting the observed form data.
A preview of how it looks after is shown below:
{F8415866}
Try: https://treeherder.mozilla.org/jobs?repo=try&revision=432567642b6dd290cf7f3b34cc2638d0982a6528
Differential Revision: https://phabricator.services.mozilla.com/D224803
552 lines
16 KiB
JavaScript
552 lines
16 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;
|
|
|
|
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 {Array<object>} fieldDetails 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(
|
|
fieldDetails,
|
|
undetected,
|
|
autocomplete,
|
|
regexp,
|
|
includeMultiPart
|
|
) {
|
|
let extra = this.#initFormEventExtra(undetected);
|
|
|
|
let identified = new Set();
|
|
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(flowId, fieldDetails) {
|
|
this.recordFormEvent(
|
|
"detected",
|
|
flowId,
|
|
this.#buildFormDetectedEventExtra(
|
|
fieldDetails,
|
|
"false",
|
|
"true",
|
|
"0",
|
|
false
|
|
)
|
|
);
|
|
|
|
this.recordGleanFormEvent(
|
|
"formDetected",
|
|
flowId,
|
|
this.#buildFormDetectedEventExtra(
|
|
fieldDetails,
|
|
"undetected",
|
|
"autocomplete",
|
|
"regexp",
|
|
true
|
|
)
|
|
);
|
|
|
|
try {
|
|
this.recordIframeLayoutDetection(flowId, fieldDetails);
|
|
} catch {}
|
|
}
|
|
|
|
recordPopupShown(flowId, fieldDetails) {
|
|
const extra = { field_name: fieldDetails[0].fieldName };
|
|
this.recordFormEvent("popup_shown", flowId, extra);
|
|
this.recordGleanFormEvent("formPopupShown", flowId, extra);
|
|
}
|
|
|
|
recordFormFilled(flowId, 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 } = data.get(fieldDetail.elementId) ?? {};
|
|
switch (filledState) {
|
|
case FIELD_STATES.AUTO_FILLED:
|
|
filledState = "filled";
|
|
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("filled", flowId, extra);
|
|
this.recordGleanFormEvent("formFilled", flowId, extra);
|
|
}
|
|
|
|
recordFilledModified(flowId, fieldDetails) {
|
|
const extra = { field_name: fieldDetails[0].fieldName };
|
|
this.recordFormEvent("filled_modified", flowId, extra);
|
|
this.recordGleanFormEvent("formFilledModified", 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);
|
|
this.recordGleanFormEvent("formSubmitted", 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);
|
|
this.recordGleanFormEvent("formCleared", flowId, extra);
|
|
}
|
|
|
|
recordFormEvent(_method, _flowId, _extra) {
|
|
throw new Error("Not implemented.");
|
|
}
|
|
|
|
recordGleanFormEvent(_eventName, _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_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.");
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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";
|
|
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
Services.telemetry.scalarSet(this.SCALAR_AUTOFILL_PROFILE_COUNT, count);
|
|
}
|
|
}
|
|
|
|
class CreditCardTelemetry extends AutofillTelemetryBase {
|
|
EVENT_CATEGORY = "creditcard";
|
|
EVENT_OBJECT_FORM_INTERACTION = "CcFormV2";
|
|
|
|
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);
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
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 #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);
|
|
}
|
|
|
|
/**
|
|
* Utility functions for submitted section count scalar (defined in Scalars.yaml)
|
|
*
|
|
* Category: formautofill.creditCards or formautofill.addresses
|
|
* Scalar name: submitted_sections_count
|
|
*/
|
|
static recordDetectedSectionCount(fieldDetails) {
|
|
const telemetry = this.#getTelemetryByFieldDetail(fieldDetails[0]);
|
|
telemetry.recordDetectedSectionCount();
|
|
}
|
|
|
|
static recordSubmittedSectionCount(fieldDetails, count) {
|
|
const telemetry = this.#getTelemetryByFieldDetail(fieldDetails[0]);
|
|
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);
|
|
}
|
|
}
|