From aa3a9060cb7a564d8b0c3f6843bf7e874d5edaa0 Mon Sep 17 00:00:00 2001 From: Jody Heavener Date: Mon, 3 Aug 2020 14:48:55 -0400 Subject: [PATCH] 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 --- .../app/scripts/lib/metrics.js | 9 + .../app/scripts/views/get_flow.js | 16 +- .../app/tests/spec/views/get_flow.js | 14 +- packages/fxa-settings/README.md | 24 ++ .../src/components/App/index.test.tsx | 20 +- .../fxa-settings/src/components/App/index.tsx | 8 +- .../src/components/Settings/index.tsx | 8 + packages/fxa-settings/src/index.tsx | 6 +- .../fxa-settings/src/lib/flow-event.test.ts | 91 ---- packages/fxa-settings/src/lib/flow-event.ts | 116 ------ packages/fxa-settings/src/lib/metrics.test.ts | 394 ++++++++++++++++++ packages/fxa-settings/src/lib/metrics.ts | 362 ++++++++++++++++ packages/fxa-settings/src/react-app-env.d.ts | 13 +- 13 files changed, 846 insertions(+), 235 deletions(-) delete mode 100644 packages/fxa-settings/src/lib/flow-event.test.ts delete mode 100644 packages/fxa-settings/src/lib/flow-event.ts create mode 100644 packages/fxa-settings/src/lib/metrics.test.ts create mode 100644 packages/fxa-settings/src/lib/metrics.ts diff --git a/packages/fxa-content-server/app/scripts/lib/metrics.js b/packages/fxa-content-server/app/scripts/lib/metrics.js index 1fa3dfc2e7..b3e029cfab 100644 --- a/packages/fxa-content-server/app/scripts/lib/metrics.js +++ b/packages/fxa-content-server/app/scripts/lib/metrics.js @@ -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); diff --git a/packages/fxa-content-server/app/scripts/views/get_flow.js b/packages/fxa-content-server/app/scripts/views/get_flow.js index 241a12fc8b..4ff9522db4 100644 --- a/packages/fxa-content-server/app/scripts/views/get_flow.js +++ b/packages/fxa-content-server/app/scripts/views/get_flow.js @@ -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, }); diff --git a/packages/fxa-content-server/app/tests/spec/views/get_flow.js b/packages/fxa-content-server/app/tests/spec/views/get_flow.js index 31015757f0..34f3c3ef13 100644 --- a/packages/fxa-content-server/app/tests/spec/views/get_flow.js +++ b/packages/fxa-content-server/app/tests/spec/views/get_flow.js @@ -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(() => { diff --git a/packages/fxa-settings/README.md b/packages/fxa-settings/README.md index 348483b081..bf8f8b0f6e 100644 --- a/packages/fxa-settings/README.md +++ b/packages/fxa-settings/README.md @@ -203,6 +203,30 @@ const LogoImage = () => ( const LogoImage = () => ; ``` +### 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/`. diff --git a/packages/fxa-settings/src/components/App/index.test.tsx b/packages/fxa-settings/src/components/App/index.test.tsx index e4a76f27a3..743b8f4141 100644 --- a/packages/fxa-settings/src/components/App/index.test.tsx +++ b/packages/fxa-settings/src/components/App/index.test.tsx @@ -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(); }); diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 50b7df79e5..84ba58457f 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -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 ( diff --git a/packages/fxa-settings/src/components/Settings/index.tsx b/packages/fxa-settings/src/components/Settings/index.tsx index 1d527500d4..b2272e1a57 100644 --- a/packages/fxa-settings/src/components/Settings/index.tsx +++ b/packages/fxa-settings/src/components/Settings/index.tsx @@ -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', diff --git a/packages/fxa-settings/src/index.tsx b/packages/fxa-settings/src/index.tsx index 8f3d1c7677..98d646bcad 100644 --- a/packages/fxa-settings/src/index.tsx +++ b/packages/fxa-settings/src/index.tsx @@ -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( - + diff --git a/packages/fxa-settings/src/lib/flow-event.test.ts b/packages/fxa-settings/src/lib/flow-event.test.ts deleted file mode 100644 index 91200bc022..0000000000 --- a/packages/fxa-settings/src/lib/flow-event.test.ts +++ /dev/null @@ -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', -// }, -// }); -// }); diff --git a/packages/fxa-settings/src/lib/flow-event.ts b/packages/fxa-settings/src/lib/flow-event.ts deleted file mode 100644 index d61b31b6ef..0000000000 --- a/packages/fxa-settings/src/lib/flow-event.ts +++ /dev/null @@ -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, -}; diff --git a/packages/fxa-settings/src/lib/metrics.test.ts b/packages/fxa-settings/src/lib/metrics.test.ts new file mode 100644 index 0000000000..b543d5e4fe --- /dev/null +++ b/packages/fxa-settings/src/lib/metrics.test.ts @@ -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(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( + window.document.documentElement, + 'clientHeight', + 600 + ), + clientWidth: redefineProp( + window.document.documentElement, + 'clientWidth', + 800 + ), + devicePixelRatio: redefineProp(window, 'devicePixelRatio', 2), + height: redefineProp(window.screen, 'height', 600), + width: redefineProp(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, + }, + ], + }); + }); +}); diff --git a/packages/fxa-settings/src/lib/metrics.ts b/packages/fxa-settings/src/lib/metrics.ts new file mode 100644 index 0000000000..999b374755 --- /dev/null +++ b/packages/fxa-settings/src/lib/metrics.ts @@ -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 | typeof NOT_REPORTED_VALUE; + +type ScreenInfo = { + clientHeight: Optional; + clientWidth: Optional; + devicePixelRatio: Optional; + height: Optional; + width: Optional; +}; + +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; + userPreferences: UserPreferences; + utm_campaign: Optional; + utm_content: Optional; + utm_medium: Optional; + utm_source: Optional; + utm_term: Optional; +}; + +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) { + // 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 = {} +) { + 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 = {} +) { + 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 = {}, + 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 = {} +) { + 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; +} diff --git a/packages/fxa-settings/src/react-app-env.d.ts b/packages/fxa-settings/src/react-app-env.d.ts index cf26a97dff..5a28b860b3 100644 --- a/packages/fxa-settings/src/react-app-env.d.ts +++ b/packages/fxa-settings/src/react-app-env.d.ts @@ -8,8 +8,13 @@ type hexstring = string; type Hash = { [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; }