430 lines
13 KiB
JavaScript
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);
|
|
}
|
|
}
|