feat(settings): metrics reporting

Because

- The new Settings app needs to be able to report metrics events

This commit

- Starts implementing full metrics reporting to the content server metrics endpoint
This commit is contained in:
Jody Heavener 2020-08-03 14:48:55 -04:00
Родитель ef378f951b
Коммит aa3a9060cb
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 065C43AE0EE5A19A
13 изменённых файлов: 846 добавлений и 235 удалений

Просмотреть файл

@ -509,6 +509,15 @@ _.extend(Metrics.prototype, Backbone.Events, {
});
},
/**
* Get a value from filtered data
*
* @returns {*}
*/
getFilteredValue(key) {
return this.getFilteredData()[key];
},
_send(data, isPageUnloading) {
const url = `${this._collector}/metrics`;
const payload = JSON.stringify(data);

Просмотреть файл

@ -49,6 +49,11 @@ class GetFlowView extends BaseView {
flowBeginTime,
flowId,
} = this.metrics.getFlowEventMetadata();
const broker = this.metrics.getFilteredValue('broker');
const context = this.metrics.getFilteredValue('context');
const isSampledUser = this.metrics.getFilteredValue('isSampledUser');
const service = this.metrics.getFilteredValue('service');
const uniqueUserId = this.metrics.getFilteredValue('uniqueUserId');
let redirectPath = redirectTo;
let redirectParams = {};
@ -58,9 +63,14 @@ class GetFlowView extends BaseView {
}
const queryString = Url.objToSearchString({
device_id: deviceId,
flow_begin_time: flowBeginTime,
flow_id: flowId,
deviceId,
flowBeginTime,
flowId,
broker,
context,
isSampledUser,
service,
uniqueUserId,
...redirectParams,
});

Просмотреть файл

@ -44,6 +44,7 @@ describe('views/get_flow', function () {
sinon.stub(view, 'initializeFlowEvents');
sinon.stub(view, 'navigateAway');
sinon.stub(view.metrics, 'getFlowEventMetadata').returns(flowData);
sinon.stub(view.metrics, 'getFilteredValue').returns('foo');
sinon.stub(view.window.console, 'error');
});
@ -52,17 +53,20 @@ describe('views/get_flow', function () {
$(view.el).remove();
view.window.console.error.restore();
view.metrics.getFlowEventMetadata.restore();
view.metrics.getFilteredValue.restore();
view.destroy();
view = null;
});
describe('render with proper params', () => {
const betaSettingsPath = '/beta/settings';
let queryParams = {
device_id: flowData.deviceId,
flow_begin_time: flowData.flowBeginTime,
flow_id: flowData.flowId,
};
let queryParams = Object.assign(flowData, {
broker: 'foo',
context: 'foo',
isSampledUser: 'foo',
service: 'foo',
uniqueUserId: 'foo',
});
describe('correct redirect_path', () => {
beforeEach(() => {

Просмотреть файл

@ -203,6 +203,30 @@ const LogoImage = () => (
const LogoImage = () => <div class="logo" role="img" aria-label="logo"></div>;
```
### Metrics
Metrics reports are currently sent to the fxa-content-server metrics endpoint. Use the [metrics library](./src/lib/metrics.ts) to log events and other information.
#### Payload data
Relevant environment and account data, such as window measurements, user locale, and flow data are automatically included in metrics payloads, however additional data can be set as needed:
- `setProperties({ key: value })` can be used to configure additional information about the user's environment and session. Refer to `ConfigurableProperties` in the metrics library for the properties that can be configured.
- `setUserPreference` can be used to log when a user preference is updated.
- `addExperiment(choice, group)` can be used to add details about an experiment the user is participating in.
- `addMarketingImpression(url, campaignId)` and `setMarketingClick(url, campaignId)` can be used to add details about a marketing flow the user is a part of, and whether or not a marketing link was interacted with.
#### Event logging
Log events to record when a user completes a measurable action. All previously mentioned payload data is included each time one of the following logging functions are called:
- `logViewEvent(viewName, eventName, eventProperties)` can be used to record that a particular view (a "page") was visited.
- `logExperiment(choice, group, eventProperties)` can be used to log the outcome of an experiment the user is participating in. This also calls `addExperiment` with the same choice and group.
All logging methods have the argument `eventProperties`, which can be used to supply event-specific information to the payload.
**Note:** take care when calling these methods as they attempt to log the event immediately. When logging view events inside React Components you'll want to place the call inside a `useEffect` hook to only execute on component render.
## Testing
This package uses [Jest](https://jestjs.io/) to test its code. By default `yarn test` will test all JS files under `src/`.

Просмотреть файл

@ -6,7 +6,7 @@ import React from 'react';
import { render, act } from '@testing-library/react';
import { MockedProvider, MockLink } from '@apollo/client/testing';
import App from '.';
import FlowEvent from '../../lib/flow-event';
import * as Metrics from '../../lib/metrics';
// workaround for https://github.com/apollographql/apollo-client/issues/6559
const mockLink = new MockLink([], false);
@ -15,7 +15,7 @@ mockLink.setOnError((error) => {
});
const appProps = {
queryParams: {},
flowQueryParams: {},
};
beforeEach(() => {
@ -52,12 +52,12 @@ it("doesn't redirect to /get_flow when flow data is present", async () => {
const DEVICE_ID = 'yoyo';
const BEGIN_TIME = 123456;
const FLOW_ID = 'abc123';
const flowInit = jest.spyOn(FlowEvent, 'init');
const flowInit = jest.spyOn(Metrics, 'init');
const updatedAppProps = Object.assign(appProps, {
queryParams: {
device_id: DEVICE_ID,
flow_begin_time: BEGIN_TIME,
flow_id: FLOW_ID,
flowQueryParams: {
deviceId: DEVICE_ID,
flowBeginTime: BEGIN_TIME,
flowId: FLOW_ID,
},
});
@ -70,9 +70,9 @@ it("doesn't redirect to /get_flow when flow data is present", async () => {
});
expect(flowInit).toHaveBeenCalledWith({
device_id: DEVICE_ID,
flow_id: FLOW_ID,
flow_begin_time: BEGIN_TIME,
deviceId: DEVICE_ID,
flowId: FLOW_ID,
flowBeginTime: BEGIN_TIME,
});
expect(window.location.replace).not.toHaveBeenCalled();
});

Просмотреть файл

@ -8,7 +8,7 @@ import AppLayout from '../AppLayout';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import AppErrorDialog from 'fxa-react/components/AppErrorDialog';
import Settings from '../Settings';
import FlowEvents from '../../lib/flow-event';
import * as Metrics from '../../lib/metrics';
import { Account } from '../../models';
import { Router } from '@reach/router';
import FlowContainer from '../FlowContainer';
@ -52,12 +52,12 @@ export const GET_INITIAL_STATE = gql`
`;
type AppProps = {
queryParams: QueryParams;
flowQueryParams: FlowQueryParams;
};
export const App = ({ queryParams }: AppProps) => {
export const App = ({ flowQueryParams }: AppProps) => {
const { loading, error } = useQuery<{ account: Account }>(GET_INITIAL_STATE);
FlowEvents.init(queryParams);
Metrics.init(flowQueryParams);
if (loading) {
return (

Просмотреть файл

@ -9,6 +9,8 @@ import Security from '../Security';
import UnitRowSecondaryEmail from '../UnitRowSecondaryEmail';
import { RouteComponentProps } from '@reach/router';
import AlertExternal from '../AlertExternal';
import * as Metrics from '../../lib/metrics';
import { useAccount } from '../../models';
export const Settings = (_: RouteComponentProps) => {
@ -18,8 +20,14 @@ export const Settings = (_: RouteComponentProps) => {
avatarUrl,
passwordCreated,
recoveryKey,
uid,
} = useAccount();
Metrics.setProperties({
lang: document.querySelector('html')?.getAttribute('lang'),
uid,
});
const pwdDateText = Intl.DateTimeFormat('default', {
year: 'numeric',
month: 'numeric',

Просмотреть файл

@ -22,14 +22,16 @@ try {
const authClient = createAuthClient(config.servers.auth.url);
const apolloClient = createApolloClient(config.servers.gql.url);
const queryParams = searchParams(window.location.search);
const flowQueryParams = searchParams(
window.location.search
) as FlowQueryParams;
render(
<React.StrictMode>
<ApolloProvider client={apolloClient}>
<AuthContext.Provider value={{ auth: authClient }}>
<AppErrorBoundary>
<App {...{ queryParams }} />
<App {...{ flowQueryParams }} />
</AppErrorBoundary>
</AuthContext.Provider>
</ApolloProvider>

Просмотреть файл

@ -1,91 +0,0 @@
/* 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 FlowEvent from './flow-event';
import sentryMetrics from 'fxa-shared/lib/sentry';
const eventGroup = 'testo';
const eventType = 'quuz';
const mockNow = 1002003004005;
beforeEach(() => {
// `sendBeacon` is undefined in this context
window.navigator.sendBeacon = jest.fn();
});
it('does not send metrics when uninitialized', () => {
FlowEvent.logAmplitudeEvent(eventGroup, eventType, {});
expect(window.navigator.sendBeacon).not.toHaveBeenCalled();
});
it('remains uninitialized when any flow param is empty', () => {
FlowEvent.init({});
FlowEvent.logAmplitudeEvent(eventGroup, eventType, {});
FlowEvent.init({ device_id: 'moz9000', flow_begin_time: 9001 });
FlowEvent.logAmplitudeEvent(eventGroup, eventType, {});
FlowEvent.init({ device_id: 'moz9000', flow_id: 'ipsoandfacto' });
FlowEvent.logAmplitudeEvent(eventGroup, eventType, {});
FlowEvent.init({ flow_begin_time: 9001, flow_id: 'ipsoandfacto' });
FlowEvent.logAmplitudeEvent(eventGroup, eventType, {});
expect(window.navigator.sendBeacon).not.toHaveBeenCalled();
});
it('logs and captures an exception from postMetrics', () => {
const mockCapture = jest
.spyOn(sentryMetrics, 'captureException')
.mockImplementation();
const error = 'oops';
(window.navigator.sendBeacon as jest.Mock).mockImplementation(() => {
throw error;
});
FlowEvent.init({
device_id: 'moz9000',
flow_begin_time: 9001,
flow_id: 'ipsoandfacto',
});
FlowEvent.logAmplitudeEvent(eventGroup, eventType, {});
expect(mockCapture).toBeCalledWith(error);
expect(global.console.error).toBeCalledWith('AppError', error);
});
it('initializes when given all flow params', () => {
FlowEvent.init({
device_id: 'moz9000',
flow_begin_time: 9001,
flow_id: 'ipsoandfacto',
});
FlowEvent.logAmplitudeEvent(eventGroup, eventType, {});
expect(window.navigator.sendBeacon).toHaveBeenCalled();
});
// FIXME: disabled under we sort out
// data params see flow-events.ts
//
// it('sends the correct Amplitude metric payload', () => {
// FlowEvent.logAmplitudeEvent(eventGroup, eventType, {
// quuz: 'quux',
// });
// const [metricsPath, payload] = (window.navigator
// .sendBeacon as jest.Mock).mock.calls[0];
// expect(metricsPath).toEqual(`/metrics`);
// expect(JSON.parse(payload)).toMatchObject({
// events: [
// {
// offset: expect.any(Number),
// type: `amplitude.${eventGroup}.${eventType}`,
// },
// ],
// data: {
// flowId: 'ipsoandfacto',
// flowBeginTime: expect.any(Number),
// deviceId: 'moz9000',
// },
// });
// });

Просмотреть файл

@ -1,116 +0,0 @@
/* 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 sentryMetrics from 'fxa-shared/lib/sentry';
interface FlowEventParams {
device_id?: string;
flow_id?: string;
flow_begin_time?: number;
}
interface FlowEventData {
deviceId: string;
flowBeginTime: number;
flowId: string;
}
let initialized = false;
let flowEventData: FlowEventData;
function shouldSend() {
return initialized && window.navigator.sendBeacon;
}
function postMetrics(eventData: object) {
// This is not an Action insofar that it has no bearing on the app state.
window.navigator.sendBeacon('/metrics', JSON.stringify(eventData));
}
export function init(eventData: FlowEventParams) {
if (!initialized) {
if (eventData.device_id && eventData.flow_begin_time && eventData.flow_id) {
flowEventData = {
deviceId: eventData.device_id,
flowBeginTime: eventData.flow_begin_time,
flowId: eventData.flow_id,
};
initialized = true;
} else {
let redirectPath = window.location.pathname;
if (window.location.search) {
redirectPath += window.location.search;
}
return window.location.replace(
`${window.location.origin}/get_flow?redirect_to=${encodeURIComponent(
redirectPath
)}`
);
}
}
}
export function logAmplitudeEvent(
groupName: string,
eventName: string,
eventProperties: object
) {
if (!shouldSend()) {
return;
}
try {
const now = Date.now();
// TODO: The following are the parameters required
// for the content server to receive a metrics report.
// They still need to be cleaned up and properly set.
const eventData = {
broker: 'web',
context: 'web',
duration: 1234,
experiments: [],
marketing: [],
isSampledUser: false,
lang: 'unknown',
referrer: 'none',
service: 'none',
startTime: 1234,
uid: 'none',
uniqueUserId: 'none',
utm_campaign: 'none',
utm_content: 'none',
utm_medium: 'none',
utm_source: 'none',
utm_term: 'none',
screen: {
clientHeight: 0,
clientWidth: 0,
devicePixelRatio: 0,
height: 0,
width: 0,
},
events: [
{
offset: now - flowEventData.flowBeginTime || 0,
type: `amplitude.${groupName}.${eventName}`,
},
],
flushTime: now,
...flowEventData,
...eventProperties,
};
postMetrics(eventData);
} catch (e) {
console.error('AppError', e);
sentryMetrics.captureException(e);
}
}
export default {
init,
logAmplitudeEvent,
};

Просмотреть файл

@ -0,0 +1,394 @@
/* 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 {
init,
reset,
setProperties,
setViewNamePrefix,
logEvents,
logViewEvent,
logExperiment,
addExperiment,
setUserPreference,
setNewsletters,
addMarketingImpression,
setMarketingClick,
} from './metrics';
import sentryMetrics from 'fxa-shared/lib/sentry';
const deviceId = 'v8v0b6';
const flowBeginTime = 1589394215438;
const flowId = 'lWOSc42Ga5g';
const eventGroup = 'great';
const eventType = 'escape';
const eventSlug = `${eventGroup}.${eventType}`;
const dateNow = 1596647781678;
// Sometimes native properties are read-only,
// and cannot be traditionally mocked, so let's
// make it writeable and optionally force the value
function redefineProp<T>(o: any, p: string, value?: T): T {
const newConfig: {
writeable: true;
value?: any;
} = {
writeable: true,
};
if (value) {
newConfig.value = value;
}
Object.defineProperty(o, p, newConfig);
return value!;
}
function initFlow() {
init({
deviceId,
flowBeginTime,
flowId,
});
}
function initAndLog(slug = eventSlug, eventProperties = {}) {
initFlow();
logEvents([slug], eventProperties);
}
function parsePayloadData() {
const sendBeaconMock = (window.navigator.sendBeacon as jest.Mock).mock;
let payloadData;
try {
payloadData = JSON.parse(sendBeaconMock.calls[0][1]);
} catch (error) {
console.log('There was an issue parsing the payload data', error);
}
return payloadData;
}
function expectPayloadProperties(expected = {}) {
const payloadData = parsePayloadData();
expect(payloadData).toMatchObject(expected);
}
function expectPayloadEvents(expected: string[] = []) {
const payloadData = parsePayloadData();
const eventSlugs = payloadData.events.map(
(event: { type: string }) => event.type
);
expect(eventSlugs).toEqual(expected);
}
beforeEach(() => {
Date.now = jest.fn(() => dateNow);
window.navigator.sendBeacon = jest.fn();
});
afterEach(() => {
reset();
(Date.now as jest.Mock).mockRestore();
(window.navigator.sendBeacon as jest.Mock).mockRestore();
});
describe('init', () => {
beforeEach(() => {
window.location.replace = jest.fn();
});
it('remains uninitialized when any flow param is empty', () => {
init({});
logEvents([eventSlug]);
init({ deviceId, flowBeginTime });
logEvents([eventSlug]);
init({ deviceId, flowId });
logEvents([eventSlug]);
init({ flowBeginTime, flowId });
logEvents([eventSlug]);
expect(window.navigator.sendBeacon).not.toHaveBeenCalled();
// 4 attempts to initialize; each failing and should
// result in be redirected to the get_flow route
expect(window.location.replace).toHaveBeenCalledTimes(4);
});
it('initializes when given all flow params', () => {
initAndLog();
expect(window.navigator.sendBeacon).toHaveBeenCalled();
});
});
describe('postMetrics', () => {
it('does not send metrics when uninitialized', () => {
logEvents([eventSlug]);
expect(window.navigator.sendBeacon).not.toHaveBeenCalled();
});
});
describe('setProperties', () => {
it('can set a configurable properties to be included in the payload', () => {
const configurables = {
isSampledUser: false,
broker: 'dream for dreaming',
};
setProperties(configurables);
initAndLog();
expectPayloadProperties(configurables);
});
it('does not allow you to set a configurable property to a null value', () => {
initFlow();
logEvents([eventSlug]);
expectPayloadProperties({ utm_source: 'none' });
setProperties({ utm_source: null });
initFlow();
logEvents([eventSlug]);
expectPayloadProperties({ utm_source: 'none' });
});
});
describe('logEvents', () => {
it('transforms event slugs into proper event objects for the payload', () => {
const eventSlug = 'wild.flower';
initAndLog(eventSlug);
expectPayloadProperties({
// this timestamp comes from stubbing Date.now and flowBeginTime
events: [{ type: eventSlug, offset: 7253566240 }],
});
});
it('includes passed in eventProperties in the payload', () => {
const eventProperties = {
loveSongs: 'for robots',
goodMorning: 'mr wolf',
};
initAndLog(eventSlug, eventProperties);
expectPayloadProperties(eventProperties);
});
it('sets the flushTime property on the payload', () => {
initAndLog();
expectPayloadProperties({
flushTime: dateNow,
});
});
it('sets the referrer property on the payload', () => {
const referrer = 'turn into the noise';
redefineProp(window.document, 'referrer', referrer);
initAndLog();
expectPayloadProperties({
referrer,
});
});
it('sets the screen property on the payload', () => {
const screen = {
clientHeight: redefineProp<number>(
window.document.documentElement,
'clientHeight',
600
),
clientWidth: redefineProp<number>(
window.document.documentElement,
'clientWidth',
800
),
devicePixelRatio: redefineProp<number>(window, 'devicePixelRatio', 2),
height: redefineProp<number>(window.screen, 'height', 600),
width: redefineProp<number>(window.screen, 'width', 800),
};
initAndLog();
expectPayloadProperties({
screen,
});
});
it('sets the flow event data properties on the payload', () => {
initAndLog();
expectPayloadProperties({
deviceId,
flowBeginTime,
flowId,
});
});
});
describe('setViewNamePrefix', () => {
it('sets the view name prefix to be included in view event slugs', () => {
const prefix = 'tightrope';
setViewNamePrefix(prefix);
initFlow();
logViewEvent(eventGroup, eventType);
expectPayloadEvents([`${prefix}.${eventSlug}`]);
});
});
describe('logViewEvent', () => {
it('logs a view event', () => {
initFlow();
logViewEvent(eventGroup, eventType);
expectPayloadEvents([eventSlug]);
});
it('supports additional event properties', () => {
const eventProperties = {
cute: 'thing',
antarctigo: 'vespucci',
};
initFlow();
logViewEvent(eventGroup, eventType, eventProperties);
expectPayloadEvents([eventSlug]);
expectPayloadProperties(eventProperties);
});
});
describe('logExperiment', () => {
it('logs an experiment event', () => {
initFlow();
logExperiment(eventGroup, eventType);
expectPayloadEvents([`experiment.${eventSlug}`]);
});
it('supports additional event properties', () => {
const eventProperties = {
for: 'flotsam',
prior: 'things',
};
initFlow();
logExperiment(eventGroup, eventType, eventProperties);
expectPayloadEvents([`experiment.${eventSlug}`]);
expectPayloadProperties(eventProperties);
});
it('adds experiment information to the payload', () => {
initFlow();
logExperiment(eventGroup, eventType);
expectPayloadProperties({
experiments: [{ choice: eventGroup, group: eventType }],
});
});
});
describe('addExperiment', () => {
it('adds experiment information to the experiments property on the payload', () => {
addExperiment(eventGroup, eventType);
initAndLog();
expectPayloadProperties({
experiments: [{ choice: eventGroup, group: eventType }],
});
});
});
describe('setUserPreference', () => {
it('adds to the userPreferences property on the payload', () => {
const userPreferences = { crookedTeeth: true };
setUserPreference('crookedTeeth', userPreferences.crookedTeeth);
initAndLog();
expectPayloadProperties({
userPreferences,
});
});
});
describe('setNewsletters', () => {
it('sets the newsletters property on the payload', () => {
const newsletters = ['mabu'];
setNewsletters(newsletters);
initAndLog();
expectPayloadProperties({
newsletters,
});
});
});
describe('addMarketingImpression', () => {
it('adds impression information to the marketing property on the payload', () => {
const url = 'earl';
const campaignId = 'companidy';
addMarketingImpression(url, campaignId);
initAndLog();
expectPayloadProperties({
marketing: [
{
url,
campaignId,
clicked: false,
},
],
});
});
});
describe('setMarketingClick', () => {
it('updates a marketing impression to indicate it was interacted with', () => {
const url = 'earl';
const campaignId = 'companidy';
const url2 = 'grey';
const campaignId2 = 'zilla';
addMarketingImpression(url, campaignId);
addMarketingImpression(url2, campaignId2);
setMarketingClick(url, campaignId);
initAndLog();
expectPayloadProperties({
marketing: [
{
url,
campaignId,
clicked: true,
},
{
url: url2,
campaignId: campaignId2,
clicked: false,
},
],
});
});
});

Просмотреть файл

@ -0,0 +1,362 @@
/* 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 sentryMetrics from 'fxa-shared/lib/sentry';
import { useEffect } from 'react';
const NOT_REPORTED_VALUE = 'none';
const UNKNOWN_VALUE = 'unknown';
type Optional<T> = T | typeof NOT_REPORTED_VALUE;
type ScreenInfo = {
clientHeight: Optional<number>;
clientWidth: Optional<number>;
devicePixelRatio: Optional<number>;
height: Optional<number>;
width: Optional<number>;
};
type EventSet = {
offset: number;
type: string;
};
type ExperimentGroup = {
choice: string;
group: string;
};
type MarketingCampaign = {
campaignId: string;
clicked: boolean;
url: string;
};
type UserPreferences = {
[userPref: string]: boolean;
};
type ConfigurableProperties = {
experiments: ExperimentGroup[];
lang: string | typeof UNKNOWN_VALUE;
marketing: MarketingCampaign[];
newsletters: string[];
startTime: number;
uid: Optional<string>;
userPreferences: UserPreferences;
utm_campaign: Optional<string>;
utm_content: Optional<string>;
utm_medium: Optional<string>;
utm_source: Optional<string>;
utm_term: Optional<string>;
};
type EventData = FlowQueryParams &
ConfigurableProperties & {
duration: number;
events: EventSet[];
flushTime: number;
referrer: string;
screen: ScreenInfo;
[additionalProperty: string]: any;
};
let initialized = false;
let viewNamePrefix: string | null;
let flowEventData: FlowQueryParams;
let configurableProperties: ConfigurableProperties = defaultConfigProps();
function defaultConfigProps(): ConfigurableProperties {
return {
experiments: [],
lang: UNKNOWN_VALUE,
marketing: [],
newsletters: [],
// TODO: performance.timing.navigationStart should work, but doesn't
startTime: 123,
uid: NOT_REPORTED_VALUE,
userPreferences: {},
utm_campaign: NOT_REPORTED_VALUE,
utm_content: NOT_REPORTED_VALUE,
utm_medium: NOT_REPORTED_VALUE,
utm_source: NOT_REPORTED_VALUE,
utm_term: NOT_REPORTED_VALUE,
};
}
/**
* Get user's window/screen info
*/
function getScreenInfo(): ScreenInfo {
const documentElement = window.document.documentElement || {};
const screen = window.screen || {};
return {
clientHeight: documentElement.clientHeight || NOT_REPORTED_VALUE,
clientWidth: documentElement.clientWidth || NOT_REPORTED_VALUE,
devicePixelRatio: window.devicePixelRatio || NOT_REPORTED_VALUE,
height: screen.height || NOT_REPORTED_VALUE,
width: screen.width || NOT_REPORTED_VALUE,
};
}
/**
* Send metrics data to the content-server metrics endpoint
*
* @param eventData
*/
async function postMetrics(eventData: EventData) {
if (!initialized || !window.navigator.sendBeacon) {
return;
}
if ('keepalive' in new Request('')) {
await fetch('/metrics', {
method: 'POST',
body: JSON.stringify(eventData),
keepalive: true,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
} else {
window.navigator.sendBeacon('/metrics', JSON.stringify(eventData));
}
}
/**
* Reset Metrics setup; used for testing
*/
export function reset() {
initialized = false;
viewNamePrefix = null;
flowEventData = {};
configurableProperties = defaultConfigProps();
}
/**
* Initialize FxA flow metrics recording
*
* If all flow data is not present, will redirect back to the
* content-server to retrieve a new set of flow data
*
* @param flowQueryParams - Flow data sent via query params from the content-server
*/
export function init(flowQueryParams: FlowQueryParams) {
if (!initialized) {
// Only initialize if we have the critical flow pieces
if (
flowQueryParams.deviceId &&
flowQueryParams.flowBeginTime &&
flowQueryParams.flowId
) {
flowEventData = flowQueryParams;
initialized = true;
} else {
let redirectPath = window.location.pathname;
if (window.location.search) {
redirectPath += window.location.search;
}
return window.location.replace(
`${window.location.origin}/get_flow?redirect_to=${encodeURIComponent(
redirectPath
)}`
);
}
}
}
/**
* Set the value of multiple configurable metrics event properties
*
* @param properties - Any ConfigurableProperties you wish to assign values to
*/
export function setProperties(properties: Hash<any>) {
// Feature, but also protection: guard against setting a property
// with a null value; defaults set in defaultConfigProps should
// remain the "unset" value of a configurable property
Object.keys(properties).forEach(
(key) => properties[key] == null && delete properties[key]
);
configurableProperties = Object.assign(configurableProperties, properties);
}
/**
* Set the view name prefix for metrics that contain a viewName.
* This is used to differentiate between flows when the same
* URL can appear in more than one place in the flow, e.g., the
* /sms screen. The /sms screen can be displayed in either the
* signup or verification tab, and we want to be able to
* differentiate between the two.
*
* This prefix is prepended to the view name anywhere a view
* name is used.
*
* @param prefix
*/
export function setViewNamePrefix(prefix: string) {
viewNamePrefix = prefix;
}
/**
* Initialize a payload of metric event data to the metrics endpoint
*
* @param eventSlugs - Events to log; converted to proper event groups
* @param eventProperties - Additional properties to log with the events
*/
export function logEvents(
eventSlugs: string[] = [],
eventProperties: Hash<any> = {}
) {
try {
const now = Date.now();
const eventOffset = now - flowEventData.flowBeginTime!;
postMetrics(
Object.assign(configurableProperties, {
duration: 0, // TODO where is this set?
events: eventSlugs.map((slug) => ({ type: slug, offset: eventOffset })),
flushTime: now,
referrer: window.document.referrer || NOT_REPORTED_VALUE,
screen: getScreenInfo(),
...flowEventData,
...eventProperties,
})
);
} catch (e) {
console.error('AppError', e);
sentryMetrics.captureException(e);
}
}
/**
* Log an event with the view name as a prefix
*
* @param viewName
* @param eventName
* @param eventProperties - Additional properties to log with the event
*/
export function logViewEvent(
viewName: string,
eventName: string,
eventProperties: Hash<any> = {}
) {
if (viewNamePrefix) {
viewName = `${viewNamePrefix}.${viewName}`;
}
logEvents([`${viewName}.${eventName}`], eventProperties);
}
/**
* React Hook to execute logViewEvent on component initial render
*
* @param viewName
* @param eventName
* @param eventProperties - Additional properties to log with the event
* @param dependencies - values the effect depends on, changes to them will trigger a refire
*/
export function useViewEvent(
viewName: string,
eventName: string,
eventProperties: Hash<any> = {},
dependencies: any[] = []
) {
useEffect(() => {
logViewEvent(viewName, eventName, eventProperties);
}, dependencies);
}
/**
* Log when an experiment is shown to the user
*
* @param choice - Type of experiment
* @param group - Experiment group (treatment or control)
* @param eventProperties - Additional properties to log with the event
*/
export function logExperiment(
choice: string,
group: string,
eventProperties: Hash<any> = {}
) {
addExperiment(choice, group);
logEvents([`experiment.${choice}.${group}`], eventProperties);
}
/**
* Log when a user preference is updated. Example, two step
* authentication, adding recovery email or recovery key.
*
* @param prefName - Name of preference, typically view name
*/
export function setUserPreference(prefName: string, value: boolean) {
configurableProperties.userPreferences = { [prefName]: value };
}
/**
* Log subscribed newsletters for a user.
*
* @param newsletters
*/
export function setNewsletters(newsletters: string[]) {
configurableProperties.newsletters = newsletters;
}
/**
* Log participating experiment for a user.
*
* @param choice - Type of experiment
* @param group - Experiment group (treatment or control)
*/
export function addExperiment(choice: string, group: string) {
const index = configurableProperties.experiments.findIndex(
(experiment) => experiment.choice === choice
);
const experiment: ExperimentGroup = { choice, group };
if (~index) {
configurableProperties.experiments[index] = experiment;
} else {
configurableProperties.experiments.push(experiment);
}
}
/**
* Log when a marketing snippet is shown to the user
*
* @param url - URL of marketing link
* @param campaignId - Marketing campaign id
*/
export function addMarketingImpression(url: string, campaignId?: string) {
const impression: MarketingCampaign = {
campaignId: campaignId || UNKNOWN_VALUE,
url,
clicked: false,
};
configurableProperties.marketing.push(impression);
}
/**
* Log whether the user clicked on a marketing link
*
* @param url - URL of marketing link
* @param campaignId - Marketing campaign id
*/
export function setMarketingClick(url: string, campaignId: string) {
const index = configurableProperties.marketing.findIndex(
(impression) =>
impression.url === url && impression.campaignId === campaignId
);
if (index < 0) {
return;
}
configurableProperties.marketing[index].clicked = true;
}

13
packages/fxa-settings/src/react-app-env.d.ts поставляемый
Просмотреть файл

@ -8,8 +8,13 @@ type hexstring = string;
type Hash<T> = { [key: string]: T };
interface QueryParams {
device_id?: string;
flow_id?: string;
flow_begin_time?: number;
interface FlowQueryParams {
broker?: string;
context?: string;
deviceId?: string;
flowBeginTime?: number;
flowId?: string;
isSampledUser?: boolean;
service?: string;
uniqueUserId?: string;
}