зеркало из https://github.com/mozilla/fxa.git
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:
Родитель
ef378f951b
Коммит
aa3a9060cb
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче