packages/fxa-auth-server/lib/metrics/glean/index.ts (371 lines of code) (raw):
/* 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 { ConfigType } from '../../../config';
import {
createAccountsEventsEvent,
createEventsServerEventLogger,
} from './server_events';
import { version } from '../../../package.json';
import { createHash } from 'crypto';
import { AuthRequest } from '../../types';
import * as AppError from '../../error';
import { clientId as clientIdValidator } from '../../oauth/validators';
import { MetricsContext } from '@fxa/shared/metrics/glean';
// According to @types/hapi, request.auth.credentials.user is of type
// UserCredentials, which is just {}. That's not actually the case and it
// mismatches the real type, which is string. I extended AuthRequest below but
// the type, MetricsRequest is for this file only.
interface MetricsRequest extends Omit<AuthRequest, 'auth'> {
payload: Record<string, any>;
auth: { credentials: Record<string, string> };
}
type MetricsData = {
uid?: string;
reason?: string;
oauthClientId?: string;
scopes?: string | Array<string>;
};
type AdditionalMetricsCallback = (
metrics: Record<string, any>
) => Record<string, any>;
type GleanEventFnOptions = {
// for certain events, passing in the "client" ip address isn't helpful since
// the client in question is a service from an RP
skipClientIp?: boolean;
// a callback to allow the caller to pass in additional metrics
additionalMetrics?: AdditionalMetricsCallback;
};
type ErrorLoggerFnParams = {
glean: ReturnType<typeof gleanMetrics>;
request: AuthRequest;
error: AppError;
};
let appConfig: ConfigType;
let gleanEventLogger: ReturnType<typeof createAccountsEventsEvent>;
let gleanServerEventLogger: ReturnType<typeof createEventsServerEventLogger>;
const isEnabled = async (request: MetricsRequest) =>
appConfig.gleanMetrics.enabled && (await request.app.isMetricsEnabled);
const findUid = (request: MetricsRequest, metricsData?: MetricsData): string =>
metricsData?.uid ||
request.auth?.credentials?.uid ||
request.auth?.credentials?.user ||
'';
const sha256HashUid = (uid: string) =>
createHash('sha256').update(uid).digest('hex');
const findServiceName = async (request: MetricsRequest) => {
const metricsContext = await request.app.metricsContext;
return metricsContext.service || '';
};
const findOauthClientId = async (
request: MetricsRequest,
metricsData?: MetricsData
): Promise<string> => {
const clientId =
metricsData?.oauthClientId ||
request.auth.credentials?.client_id ||
request.payload?.client_id;
// for OAuth the content-server places the client id into the service
// property for metrics, so we'll check that value for something shaped like
// an oauth id
const clientIdInService = async () => {
const service = await findServiceName(request);
const { error } = clientIdValidator.validate(service);
if (!error) {
return service;
}
return null;
};
return clientId || (await clientIdInService()) || '';
};
const getMetricMethod = (eventName: string) => {
const uppercaseWords = eventName
.split('_')
.map((word) => `${word[0].toUpperCase()}${word.slice(1)}`)
.join('');
const methodName = `record${uppercaseWords}`;
if (
!gleanServerEventLogger[methodName as keyof typeof gleanServerEventLogger]
) {
process.stderr.write(
`Method ${methodName} for eventName ${eventName} not found in gleanServerEventLogger`
);
process.exit(1);
}
return gleanServerEventLogger[
methodName as keyof typeof gleanServerEventLogger
] as (args: any) => void;
};
const createEventFn =
// On MetricsData: for an event like successful login, the uid isn't known at
// the time of request since the request itself isn't authenticated. We'll
// accept data from the event logging call for metrics that are known/easily
// accessible in the calling scope but difficult/not possible to get from any
// context attached to the request.
(eventName: string, options?: GleanEventFnOptions) => {
// Resolve the Glean event metric method
const method = getMetricMethod(eventName);
const eventOptions = options || {};
return async (req: AuthRequest, metricsData?: MetricsData) => {
// where the function is called the request object is likely to be declared
// to be AuthRequest, so we do a cast here.
const request = req as unknown as MetricsRequest;
const enabled = await isEnabled(request);
if (!enabled) {
return;
}
const metricsContext: MetricsContext = await request.app.metricsContext;
// metrics sent with every event
const commonMetrics = {
user_agent: request.headers['user-agent'],
ip_address:
eventOptions.skipClientIp === true ? '' : request.app.clientAddress,
account_user_id: '',
account_user_id_sha256: '',
relying_party_oauth_client_id: await findOauthClientId(
request,
metricsData
),
relying_party_service: await findServiceName(request),
session_device_type: request.app.ua.deviceType || '',
session_entrypoint: metricsContext.entrypoint || '',
// experiment and variation were added for content server backend pings
// auth server TODO FXA-9847
session_entrypoint_experiment: '',
session_entrypoint_variation: '',
session_flow_id: metricsContext.flowId || '',
utm_campaign: metricsContext.utmCampaign || '',
utm_content: metricsContext.utmContent || '',
utm_medium: metricsContext.utmMedium || '',
utm_source: metricsContext.utmSource || '',
utm_term: metricsContext.utmTerm || '',
scopes: metricsData?.scopes || '',
};
// reason is sent in access_token_created, login_submit_backend_error, and reg_submit_error
const eventReason = metricsData?.reason || '';
// uid needs extra handling because we need to hash the value
const uid = findUid(request, metricsData);
if (uid !== '') {
commonMetrics.account_user_id = uid;
commonMetrics.account_user_id_sha256 = sha256HashUid(uid);
}
// new style Glean events with event metric type
const moreMetrics = eventOptions.additionalMetrics
? eventOptions.additionalMetrics({
...commonMetrics,
...(metricsData || {}),
})
: {};
method.call(gleanServerEventLogger, { ...commonMetrics, ...moreMetrics });
gleanEventLogger.record({
...commonMetrics,
event_name: eventName,
event_reason: eventReason,
});
};
};
const extraKeyReasonCb = (metrics: Record<string, any>) => ({
reason: metrics.reason,
});
export function gleanMetrics(config: ConfigType) {
appConfig = config;
gleanEventLogger = createAccountsEventsEvent({
applicationId: config.gleanMetrics.applicationId,
appDisplayVersion: version,
channel: config.gleanMetrics.channel,
logger_options: { app: config.gleanMetrics.loggerAppName },
});
gleanServerEventLogger = createEventsServerEventLogger({
applicationId: config.gleanMetrics.applicationId,
appDisplayVersion: version,
channel: config.gleanMetrics.channel,
logger_options: { app: config.gleanMetrics.loggerAppName },
});
return {
registration: {
accountCreated: createEventFn('reg_acc_created'),
confirmationEmailSent: createEventFn('reg_email_sent'),
accountVerified: createEventFn('reg_acc_verified'),
complete: createEventFn('reg_complete'),
error: createEventFn('reg_submit_error', {
additionalMetrics: extraKeyReasonCb,
}),
},
login: {
success: createEventFn('login_success'),
error: createEventFn('login_submit_backend_error', {
additionalMetrics: extraKeyReasonCb,
}),
totpSuccess: createEventFn('login_totp_code_success'),
totpFailure: createEventFn('login_totp_code_failure'),
recoveryCodeSuccess: createEventFn('login_backup_code_success'),
verifyCodeEmailSent: createEventFn('login_email_confirmation_sent'),
verifyCodeConfirmed: createEventFn('login_email_confirmation_success'),
complete: createEventFn('login_complete'),
},
resetPassword: {
emailSent: createEventFn('password_reset_email_sent'),
createNewSuccess: createEventFn('password_reset_create_new_success'),
accountReset: createEventFn('account_password_reset'),
recoveryKeySuccess: createEventFn('password_reset_recovery_key_success'),
otpEmailSent: createEventFn('password_reset_email_confirmation_sent'),
otpVerified: createEventFn('password_reset_email_confirmation_success'),
twoFactorSuccess: createEventFn('password_reset_two_factor_success'),
twoFactorRecoveryCodeSuccess: createEventFn(
'password_reset_recovery_code_success'
),
recoveryKeyCreatePasswordSuccess: createEventFn(
'password_reset_recovery_key_create_success'
),
},
oauth: {
tokenCreated: createEventFn('access_token_created', {
additionalMetrics: extraKeyReasonCb,
}),
tokenChecked: createEventFn('access_token_checked', {
skipClientIp: true,
additionalMetrics: (metrics) => ({
scopes: metrics.scopes
? Array.isArray(metrics.scopes)
? metrics.scopes.sort().join(',')
: metrics.scopes.split(',').sort().join(',')
: '',
}),
}),
},
thirdPartyAuth: {
googleLoginComplete: createEventFn(
'third_party_auth_google_login_complete',
{
additionalMetrics: (metrics) => ({
linking: metrics.reason === 'linking',
}),
}
),
appleLoginComplete: createEventFn(
'third_party_auth_apple_login_complete',
{
additionalMetrics: (metrics) => ({
linking: metrics.reason === 'linking',
}),
}
),
googleRegComplete: createEventFn('third_party_auth_google_reg_complete'),
appleRegComplete: createEventFn('third_party_auth_apple_reg_complete'),
setPasswordComplete: createEventFn(
'third_party_auth_set_password_complete'
),
},
account: {
deleteComplete: createEventFn('account_delete_complete'),
deleteTaskHandled: createEventFn('account_delete_task_handled', {
additionalMetrics: extraKeyReasonCb,
}),
},
twoFactorAuth: {
codeComplete: createEventFn('two_factor_auth_code_complete'),
replaceCodeComplete: createEventFn(
'two_factor_auth_replace_code_complete'
),
},
twoStepAuthPhoneCode: {
sent: createEventFn('two_step_auth_phone_code_sent'),
sendError: createEventFn('two_step_auth_phone_code_send_error'),
complete: createEventFn('two_step_auth_phone_code_complete'),
},
twoStepAuthRemove: {
success: createEventFn('two_step_auth_remove_success'),
},
emailDelivery: {
success: createEventFn('email_delivery_success', {
additionalMetrics: extraKeyReasonCb,
}),
},
inactiveAccountDeletion: {
statusChecked: createEventFn('inactive_account_deletion_status_checked'),
firstEmailTaskRequest: createEventFn(
'inactive_account_deletion_first_email_task_request'
),
firstEmailTaskEnqueued: createEventFn(
'inactive_account_deletion_first_email_task_enqueued'
),
firstEmailTaskRejected: createEventFn(
'inactive_account_deletion_first_email_task_rejected',
{
additionalMetrics: (metrics) => ({
errorCode: metrics.errorCode,
}),
}
),
firstEmailSkipped: createEventFn(
'inactive_account_deletion_first_email_skipped',
{
additionalMetrics: extraKeyReasonCb,
}
),
secondEmailTaskRequest: createEventFn(
'inactive_account_deletion_second_email_task_request'
),
secondEmailTaskEnqueued: createEventFn(
'inactive_account_deletion_second_email_task_enqueued'
),
secondEmailTaskRejected: createEventFn(
'inactive_account_deletion_second_email_task_rejected',
{
additionalMetrics: (metrics) => ({
errorCode: metrics.errorCode,
}),
}
),
secondEmailSkipped: createEventFn(
'inactive_account_deletion_second_email_skipped',
{
additionalMetrics: extraKeyReasonCb,
}
),
finalEmailTaskRequest: createEventFn(
'inactive_account_deletion_final_email_task_request'
),
finalEmailTaskEnqueued: createEventFn(
'inactive_account_deletion_final_email_task_enqueued'
),
finalEmailTaskRejected: createEventFn(
'inactive_account_deletion_final_email_task_rejected',
{
additionalMetrics: (metrics) => ({
errorCode: metrics.errorCode,
}),
}
),
finalEmailSkipped: createEventFn(
'inactive_account_deletion_final_email_skipped',
{
additionalMetrics: extraKeyReasonCb,
}
),
deletionSkipped: createEventFn(
'inactive_account_deletion_deletion_skipped',
{
additionalMetrics: extraKeyReasonCb,
}
),
deletionScheduled: createEventFn(
'inactive_account_deletion_deletion_scheduled'
),
},
twoStepAuthPhoneRemove: {
success: createEventFn('two_step_auth_phone_remove_success'),
},
};
}
export type GleanMetricsType = ReturnType<typeof gleanMetrics>;
const routePathToErrorPingFnMap = {
'/account/create': 'registration.error',
'/account/login': 'login.error',
};
const getPingFnWithPath = (path: string) =>
Object.entries(routePathToErrorPingFnMap)
.find(([k, _]) => path.endsWith(k))
?.at(1);
export const logErrorWithGlean = ({
glean,
request,
error,
}: ErrorLoggerFnParams) => {
const pingFn = getPingFnWithPath(request.path);
if (pingFn) {
const [funnel, event] = pingFn.split('.');
const funnelFns =
glean[
funnel as keyof Omit<
ReturnType<typeof gleanMetrics>,
| 'resetPassword'
| 'oauth'
| 'thirdPartyAuth'
| 'account'
| 'twoFactorAuth'
| 'twoFactorAuthSetup'
| 'inactiveAccountDeletion'
| 'twoStepAuthPhoneRemove'
| 'twoStepAuthRemove'
| 'emailDelivery'
>
];
funnelFns[event as keyof typeof funnelFns](request, {
// we use the errno's key here because the human readable error message
// can be too verbose, while the short error title is too low resolution
// since some errors are grouped under the same title (e.g. "Bad
// Request")
reason: AppError.mapErrnoToKey(error),
});
}
};