diff --git a/app/controllers/api/verify/document_capture_controller.rb b/app/controllers/api/verify/document_capture_controller.rb index 9af779eab05..276b4b474e7 100644 --- a/app/controllers/api/verify/document_capture_controller.rb +++ b/app/controllers/api/verify/document_capture_controller.rb @@ -11,6 +11,7 @@ def create liveness_checking_enabled: liveness_checking_enabled?, analytics: analytics, irs_attempts_api_tracker: irs_attempts_api_tracker, + flow_path: params[:flow_path], ).submit if result.success? diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb index 1a36d8e6343..24cd1b27f5f 100644 --- a/app/controllers/frontend_log_controller.rb +++ b/app/controllers/frontend_log_controller.rb @@ -8,6 +8,12 @@ class FrontendLogController < ApplicationController # rubocop:disable Layout/LineLength EVENT_MAP = { 'IdV: verify in person troubleshooting option clicked' => :idv_verify_in_person_troubleshooting_option_clicked, + 'IdV: location visited' => :idv_in_person_location_visited, + 'IdV: location submitted' => :idv_in_person_location_submitted, + 'IdV: prepare visited' => :idv_in_person_prepare_visited, + 'IdV: prepare submitted' => :idv_in_person_prepare_submitted, + 'IdV: switch_back visited' => :idv_in_person_switch_back_visited, + 'IdV: switch_back submitted' => :idv_in_person_switch_back_submitted, 'IdV: forgot password visited' => :idv_forgot_password, 'IdV: password confirm visited' => :idv_review_info_visited, 'IdV: password confirm submitted' => proc do |analytics| diff --git a/app/javascript/packages/analytics/index.spec.ts b/app/javascript/packages/analytics/index.spec.ts index d4f8909d0c0..8e541f492d3 100644 --- a/app/javascript/packages/analytics/index.spec.ts +++ b/app/javascript/packages/analytics/index.spec.ts @@ -1,3 +1,4 @@ +import type { SinonStub } from 'sinon'; import { trackEvent, trackError } from '@18f/identity-analytics'; import { usePropertyValue, useSandbox } from '@18f/identity-test-helpers'; @@ -55,6 +56,16 @@ describe('trackEvent', () => { ); }); }); + + context('a network error occurs in the request', () => { + beforeEach(() => { + (window.fetch as SinonStub).rejects(new TypeError()); + }); + + it('absorbs the error', async () => { + await trackEvent('name'); + }); + }); }); }); diff --git a/app/javascript/packages/analytics/index.ts b/app/javascript/packages/analytics/index.ts index 6df46168ee1..a13eab8c614 100644 --- a/app/javascript/packages/analytics/index.ts +++ b/app/javascript/packages/analytics/index.ts @@ -17,12 +17,23 @@ interface NewRelicGlobals { */ export async function trackEvent(event: string, payload: object = {}): Promise { const endpoint = getConfigValue('analyticsEndpoint'); - if (endpoint) { + if (!endpoint) { + return; + } + + try { await window.fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event, payload }), }); + } catch (error) { + // An error would only be thrown if a network error occurred during the fetch request, which is + // a scenario we can ignore. By absorbing the error, it should be assumed that an awaited call + // to `trackEvent` would never create an interrupt due to a thrown error, since an unsuccessful + // status code on the request is not an error. + // + // See: https://fetch.spec.whatwg.org/#dom-global-fetch } } diff --git a/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx index c3c2e55a847..b08b07acfaa 100644 --- a/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx @@ -7,7 +7,7 @@ import { ServiceProviderContextProvider, } from '@18f/identity-document-capture'; import { FlowContext, FlowContextValue } from '@18f/identity-verify-flow'; -import AnalyticsContext from '../context/analytics'; +import { AnalyticsContextProvider } from '../context/analytics'; import DocumentCaptureTroubleshootingOptions from './document-capture-troubleshooting-options'; import type { ServiceProviderContext } from '../context/service-provider'; @@ -165,9 +165,9 @@ describe('DocumentCaptureTroubleshootingOptions', () => { it('logs an event when clicking the troubleshooting option', async () => { const trackEvent = sinon.stub(); const { getByRole } = render( - + - , + , { wrapper }, ); diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index 4821da847fd..082cca85521 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useContext } from 'react'; +import { useState, useMemo, useContext, useEffect } from 'react'; import { Alert } from '@18f/identity-components'; import { useI18n } from '@18f/identity-react-i18n'; import { FormSteps, PromptOnNavigate } from '@18f/identity-form-steps'; @@ -14,6 +14,7 @@ import InPersonSwitchBackStep from './in-person-switch-back-step'; import ReviewIssuesStep from './review-issues-step'; import ServiceProviderContext from '../context/service-provider'; import UploadContext from '../context/upload'; +import AnalyticsContext from '../context/analytics'; import Submission from './submission'; import SubmissionStatus from './submission-status'; import { RetrySubmissionError } from './submission-complete'; @@ -58,8 +59,14 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum const { t } = useI18n(); const serviceProvider = useContext(ServiceProviderContext); const { flowPath } = useContext(UploadContext); + const { trackSubmitEvent, trackVisitEvent } = useContext(AnalyticsContext); const { inPersonURL } = useContext(FlowContext); useDidUpdateEffect(onStepChange, [stepName]); + useEffect(() => { + if (stepName) { + trackVisitEvent(stepName); + } + }, [stepName]); /** * Clears error state and sets form values for submission. @@ -182,6 +189,7 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum initialActiveErrors={initialActiveErrors} onComplete={submitForm} onStepChange={setStepName} + onStepSubmit={trackSubmitEvent} autoFocus={!!submissionError} /> diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx new file mode 100644 index 00000000000..25bed2d9031 --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx @@ -0,0 +1,43 @@ +import sinon from 'sinon'; +import { useContext } from 'react'; +import { render } from '@testing-library/react'; +import { getAllByRole } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { useSandbox } from '@18f/identity-test-helpers'; +import AnalyticsContext, { AnalyticsContextProvider } from '../context/analytics'; +import InPersonLocationStep, { LOCATIONS_URL } from './in-person-location-step'; + +describe('InPersonLocationStep', () => { + const DEFAULT_PROPS = { toPreviousStep() {}, onChange() {}, value: {} }; + + const sandbox = useSandbox(); + + beforeEach(() => { + sandbox + .stub(window, 'fetch') + .withArgs(LOCATIONS_URL) + .resolves({ + json: () => Promise.resolve([{ name: 'Baltimore' }]), + } as Response); + }); + + it('logs step submission with selected location', async () => { + const trackEvent = sinon.stub(); + function MetadataValue() { + return <>{JSON.stringify(useContext(AnalyticsContext).submitEventMetadata)}; + } + const { findByText } = render( + + + + , + ); + + const item = await findByText('Baltimore — in_person_proofing.body.location.post_office'); + const button = getAllByRole(item.closest('.location-collection-item')!, 'button')[0]; + + await userEvent.click(button); + + await findByText('{"selected_location":"Baltimore"}'); + }); +}); diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.tsx index 98d8e9824fd..43db03c1738 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-step.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useContext } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; import { PageHeading, SpinnerDots } from '@18f/identity-components'; import BackButton from './back-button'; import LocationCollection from './location-collection'; import LocationCollectionItem from './location-collection-item'; +import AnalyticsContext from '../context/analytics'; interface PostOffice { address: string; @@ -29,16 +30,9 @@ interface FormattedLocation { weekdayHours: string; } -const locationUrl = '/verify/in_person/usps_locations'; +export const LOCATIONS_URL = '/verify/in_person/usps_locations'; -const getResponse = async () => { - const response = await fetch(locationUrl).then((res) => - res.json().catch((error) => { - throw error; - }), - ); - return response; -}; +const getResponse = () => window.fetch(LOCATIONS_URL).then((res) => res.json()); const formatLocation = (postOffices: PostOffice[]) => { const formattedLocations = [] as FormattedLocation[]; @@ -79,6 +73,7 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { const [inProgress, setInProgress] = useState(false); const [autoSubmit, setAutoSubmit] = useState(false); const [isLoadingComplete, setIsLoadingComplete] = useState(false); + const { setSubmitEventMetadata } = useContext(AnalyticsContext); // ref allows us to avoid a memory leak const mountedRef = useRef(false); @@ -93,7 +88,10 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { // useCallBack here prevents unnecessary rerenders due to changing function identity const handleLocationSelect = useCallback( async (e: any, id: number) => { - onChange({ selectedLocationName: locationData[id].name }); + const selectedLocation = locationData[id]; + const { name: selectedLocationName } = selectedLocation; + setSubmitEventMetadata({ selected_location: selectedLocationName }); + onChange({ selectedLocationName }); if (autoSubmit) { return; } @@ -102,7 +100,7 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { if (inProgress) { return; } - const selected = prepToSend(locationData[id]); + const selected = prepToSend(selectedLocation); const headers = { 'Content-Type': 'application/json' }; const meta: HTMLMetaElement | null = document.querySelector('meta[name="csrf-token"]'); const csrf = meta?.content; @@ -110,7 +108,7 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { headers['X-CSRF-Token'] = csrf; } setInProgress(true); - await fetch(locationUrl, { + await fetch(LOCATIONS_URL, { method: 'PUT', body: JSON.stringify(selected), headers, diff --git a/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx index 2600ecc76e6..18203bb11a6 100644 --- a/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-prepare-step.spec.tsx @@ -1,5 +1,12 @@ -import { render } from '@testing-library/react'; -import { MarketingSiteContextProvider } from '../context'; +import sinon from 'sinon'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentType } from 'react'; +import { FlowContext } from '@18f/identity-verify-flow'; +import type { FlowContextValue } from '@18f/identity-verify-flow'; +import { useSandbox } from '@18f/identity-test-helpers'; +import { Provider as MarketingSiteContextProvider } from '../context/marketing-site'; +import { AnalyticsContextProvider } from '../context/analytics'; import InPersonPrepareStep from './in-person-prepare-step'; describe('InPersonPrepareStep', () => { @@ -16,19 +23,74 @@ describe('InPersonPrepareStep', () => { ).not.to.exist(); }); - context('with marketing site context URL', () => { - it('renders a privacy disclaimer link', () => { - const securityAndPrivacyHowItWorksURL = - 'http://example.com/security-and-privacy-how-it-works'; + context('with in person URL', () => { + const inPersonURL = '#in_person'; + const wrapper: ComponentType = ({ children }) => ( + + {children} + + ); + + it('logs prepare step submission when clicking continue', async () => { + const trackEvent = sinon.stub(); const { getByRole } = render( - + - , + , + { wrapper }, ); + await userEvent.click(getByRole('link', { name: 'forms.buttons.continue' })); + await waitFor(() => window.location.hash === inPersonURL); + + expect(trackEvent).to.have.been.calledWith('IdV: prepare submitted'); + }); + + context('when clicking in quick succession', () => { + const { clock } = useSandbox({ useFakeTimers: true }); + + it('logs submission only once', async () => { + const delay = 1000; + const trackEvent = sinon + .stub() + .callsFake(() => new Promise((resolve) => setTimeout(resolve, delay))); + const { getByRole } = render( + + + , + { wrapper }, + ); + + const link = getByRole('link', { name: 'forms.buttons.continue' }); + + const didFollowLinkOnFirstClick = fireEvent.click(link); + const didFollowLinkOnSecondClick = fireEvent.click(link); + + clock.tick(delay); + + await waitFor(() => window.location.hash === inPersonURL); + + expect(didFollowLinkOnFirstClick).to.be.false(); + expect(didFollowLinkOnSecondClick).to.be.false(); + expect(trackEvent).to.have.been.calledOnceWith('IdV: prepare submitted'); + }); + }); + }); + + context('with marketing site context URL', () => { + const securityAndPrivacyHowItWorksURL = 'http://example.com/security-and-privacy-how-it-works'; + const wrapper: ComponentType = ({ children }) => ( + + {children} + + ); + + it('renders a privacy disclaimer link', () => { + const { getByRole } = render(, { wrapper }); + const link = getByRole('link', { name: 'in_person_proofing.body.prepare.privacy_disclaimer_link links.new_window', }) as HTMLAnchorElement; diff --git a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx index 4a0581f7db5..b9e735862b2 100644 --- a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx @@ -1,6 +1,7 @@ +import { useContext, useState } from 'react'; +import type { MouseEventHandler } from 'react'; import { Alert, - Button, Link, IconList, IconListItem, @@ -9,23 +10,37 @@ import { ProcessListItem, } from '@18f/identity-components'; import { removeUnloadProtection } from '@18f/identity-url'; -import { useContext } from 'react'; import { FlowContext } from '@18f/identity-verify-flow'; import { getConfigValue } from '@18f/identity-config'; import { useI18n } from '@18f/identity-react-i18n'; import { FormStepsButton } from '@18f/identity-form-steps'; +import { SpinnerButton } from '@18f/identity-spinner-button'; import UploadContext from '../context/upload'; import MarketingSiteContext from '../context/marketing-site'; +import AnalyticsContext from '../context/analytics'; import BackButton from './back-button'; import InPersonTroubleshootingOptions from './in-person-troubleshooting-options'; function InPersonPrepareStep({ toPreviousStep, value }) { const { t } = useI18n(); + const [isSubmitting, setIsSubmitting] = useState(false); const { inPersonURL } = useContext(FlowContext); const { flowPath } = useContext(UploadContext); + const { trackEvent } = useContext(AnalyticsContext); const { securityAndPrivacyHowItWorksURL } = useContext(MarketingSiteContext); const { selectedLocationName } = value; + const onContinue: MouseEventHandler = async (event) => { + event.preventDefault(); + + if (!isSubmitting) { + setIsSubmitting(true); + removeUnloadProtection(); + await trackEvent('IdV: prepare submitted'); + window.location.href = (event.target as HTMLAnchorElement).href; + } + }; + return ( <> {selectedLocationName && ( @@ -90,9 +105,9 @@ function InPersonPrepareStep({ toPreviousStep, value }) { {flowPath === 'hybrid' && } {inPersonURL && flowPath === 'standard' && (
- +
)}

diff --git a/app/javascript/packages/document-capture/context/analytics.jsx b/app/javascript/packages/document-capture/context/analytics.jsx deleted file mode 100644 index da28a1a3fa3..00000000000 --- a/app/javascript/packages/document-capture/context/analytics.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import { createContext } from 'react'; - -/** @typedef {import('@18f/identity-analytics').trackEvent} TrackEvent */ -/** @typedef {Record} Payload */ - -/** - * @typedef PageAction - * - * @property {string=} key Short, camel-cased, dot-namespaced key describing event. - * @property {string} label Long-form, human-readable label describing event action. - * @property {Payload=} payload Additional payload arguments to log with action. - */ - -/** - * @typedef AnalyticsContext - * - * @prop {TrackEvent} trackEvent Log an action with optional payload. - */ - -const AnalyticsContext = createContext( - /** @type {AnalyticsContext} */ ({ - trackEvent: () => Promise.resolve(), - }), -); - -AnalyticsContext.displayName = 'AnalyticsContext'; - -export default AnalyticsContext; diff --git a/app/javascript/packages/document-capture/context/analytics.spec.tsx b/app/javascript/packages/document-capture/context/analytics.spec.tsx new file mode 100644 index 00000000000..d3f66767c9c --- /dev/null +++ b/app/javascript/packages/document-capture/context/analytics.spec.tsx @@ -0,0 +1,69 @@ +import sinon from 'sinon'; +import { useContext } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import type { ComponentType } from 'react'; +import AnalyticsContext, { AnalyticsContextProvider, LOGGED_STEPS } from './analytics'; + +describe('AnalyticsContextProvider', () => { + let trackEvent: sinon.SinonStub; + let wrapper: ComponentType; + beforeEach(() => { + trackEvent = sinon.stub(); + wrapper = ({ children }) => ( + {children} + ); + }); + + it('provides default context values', () => { + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + expect(result.current).to.have.all.keys([ + 'trackEvent', + 'trackSubmitEvent', + 'trackVisitEvent', + 'submitEventMetadata', + 'setSubmitEventMetadata', + ]); + }); + + it('calls trackEvent with visit event', () => { + const stepName = LOGGED_STEPS[0]; + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + result.current.trackVisitEvent(stepName); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} visited`); + }); + + it('calls trackEvent with submit event', () => { + const stepName = LOGGED_STEPS[0]; + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + result.current.trackSubmitEvent(stepName); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} submitted`, {}); + }); + + it('includes metadata in the next submit event', () => { + const stepName = LOGGED_STEPS[0]; + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + result.current.setSubmitEventMetadata({ ok: true }); + result.current.trackSubmitEvent(stepName); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${stepName} submitted`, { ok: true }); + }); + + it('does not include metadata in subsequent submit events', () => { + const firstStepName = LOGGED_STEPS[0]; + const secondStepName = LOGGED_STEPS[1]; + const { result } = renderHook(() => useContext(AnalyticsContext), { wrapper }); + + result.current.setSubmitEventMetadata({ ok: true }); + result.current.trackSubmitEvent(firstStepName); + result.current.trackSubmitEvent(secondStepName); + + expect(trackEvent).to.have.been.calledWith(`IdV: ${firstStepName} submitted`, { ok: true }); + expect(trackEvent).to.have.been.calledWith(`IdV: ${secondStepName} submitted`, {}); + }); +}); diff --git a/app/javascript/packages/document-capture/context/analytics.tsx b/app/javascript/packages/document-capture/context/analytics.tsx new file mode 100644 index 00000000000..cd1e513a466 --- /dev/null +++ b/app/javascript/packages/document-capture/context/analytics.tsx @@ -0,0 +1,86 @@ +import { createContext, useState } from 'react'; +import type { ReactNode } from 'react'; +import type { trackEvent } from '@18f/identity-analytics'; + +type EventMetadata = Record; + +type SetSubmitEventMetadata = (metadata: EventMetadata) => void; + +type TrackSubmitEvent = (stepName: string) => void; + +type TrackVisitEvent = (stepName: string) => void; + +interface AnalyticsContextValue { + /** + * Log an action with optional payload. + */ + trackEvent: typeof trackEvent; + + /** + * Callback to trigger logging when a step is submitted. + */ + trackSubmitEvent: TrackSubmitEvent; + + /** + * Callback to trigger logging when a step is visited. + */ + trackVisitEvent: TrackVisitEvent; + + /** + * Additional metadata to be included in the next tracked submit event. + */ + submitEventMetadata: EventMetadata; + + /** + * Sets additional metadata to be included in the next tracked submit event. + */ + setSubmitEventMetadata: SetSubmitEventMetadata; +} + +type AnalyticsContextProviderProps = Pick & { + children: ReactNode; +}; + +const DEFAULT_EVENT_METADATA: Record = {}; + +export const LOGGED_STEPS: string[] = ['location', 'prepare', 'switch_back']; + +const AnalyticsContext = createContext({ + trackEvent: () => Promise.resolve(), + trackSubmitEvent() {}, + trackVisitEvent() {}, + submitEventMetadata: DEFAULT_EVENT_METADATA, + setSubmitEventMetadata() {}, +}); + +AnalyticsContext.displayName = 'AnalyticsContext'; + +export function AnalyticsContextProvider({ children, trackEvent }: AnalyticsContextProviderProps) { + const [submitEventMetadata, setSubmitEventMetadataState] = useState(DEFAULT_EVENT_METADATA); + const setSubmitEventMetadata: SetSubmitEventMetadata = (metadata) => + setSubmitEventMetadataState((prevState) => ({ ...prevState, ...metadata })); + const trackSubmitEvent: TrackSubmitEvent = (stepName) => { + if (LOGGED_STEPS.includes(stepName)) { + trackEvent(`IdV: ${stepName} submitted`, submitEventMetadata); + } + + setSubmitEventMetadataState(DEFAULT_EVENT_METADATA); + }; + const trackVisitEvent: TrackVisitEvent = (stepName) => { + if (LOGGED_STEPS.includes(stepName)) { + trackEvent(`IdV: ${stepName} visited`); + } + }; + + const value = { + trackEvent, + trackVisitEvent, + trackSubmitEvent, + submitEventMetadata, + setSubmitEventMetadata, + }; + + return {children}; +} + +export default AnalyticsContext; diff --git a/app/javascript/packages/document-capture/context/index.ts b/app/javascript/packages/document-capture/context/index.ts index 66c62141ecf..c8a755f5fa3 100644 --- a/app/javascript/packages/document-capture/context/index.ts +++ b/app/javascript/packages/document-capture/context/index.ts @@ -10,7 +10,7 @@ export { default as ServiceProviderContext, Provider as ServiceProviderContextProvider, } from './service-provider'; -export { default as AnalyticsContext } from './analytics'; +export { default as AnalyticsContext, AnalyticsContextProvider } from './analytics'; export { default as FailedCaptureAttemptsContext, Provider as FailedCaptureAttemptsContextProvider, diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 9572cd89663..37dcfa8496a 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -7,7 +7,7 @@ import { AcuantContextProvider, UploadContextProvider, ServiceProviderContextProvider, - AnalyticsContext, + AnalyticsContextProvider, FailedCaptureAttemptsContextProvider, MarketingSiteContextProvider, } from '@18f/identity-document-capture'; @@ -116,7 +116,7 @@ const trackEvent: typeof baseTrackEvent = (event, payload) => { [AppContext.Provider, { value: { appName } }], [MarketingSiteContextProvider, { helpCenterRedirectURL, securityAndPrivacyHowItWorksURL }], [DeviceContext.Provider, { value: device }], - [AnalyticsContext.Provider, { value: { trackEvent } }], + [AnalyticsContextProvider, { trackEvent }], [ AcuantContextProvider, { diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 953a7ad58ce..b8e8bf063c8 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -500,6 +500,36 @@ def idv_verify_in_person_troubleshooting_option_clicked track_event('IdV: verify in person troubleshooting option clicked') end + # The user visited the in person proofing location step + def idv_in_person_location_visited(**extra) + track_event('IdV: in person proofing location visited', **extra) + end + + # The user submitted the in person proofing location step + def idv_in_person_location_submitted(**extra) + track_event('IdV: in person proofing location submitted', **extra) + end + + # The user visited the in person proofing prepare step + def idv_in_person_prepare_visited(**extra) + track_event('IdV: in person proofing prepare visited', **extra) + end + + # The user submitted the in person proofing prepare step + def idv_in_person_prepare_submitted(**extra) + track_event('IdV: in person proofing prepare submitted', **extra) + end + + # The user visited the in person proofing switch_back step + def idv_in_person_switch_back_visited(**extra) + track_event('IdV: in person proofing switch_back visited', **extra) + end + + # The user submitted the in person proofing switch_back step + def idv_in_person_switch_back_submitted(**extra) + track_event('IdV: in person proofing switch_back submitted', **extra) + end + # The user visited the "ready to verify" page for the in person proofing flow def idv_in_person_ready_to_verify_visit track_event('IdV: in person ready to verify visited') diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 755b332bf2e..d57f25ff86d 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -2,6 +2,7 @@ feature 'Analytics Regression', js: true do include IdvStepHelper + include InPersonHelper let(:user) { user_with_2fa } let(:fake_analytics) { FakeAnalytics.new } @@ -79,6 +80,54 @@ FSMv2: common_events, } end + let(:in_person_path_events) do + { + 'IdV: doc auth welcome visited' => { flow_path: 'standard', step: 'welcome', step_count: 1 }, + 'IdV: doc auth welcome submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'welcome', step_count: 1 }, + 'IdV: doc auth agreement visited' => { flow_path: 'standard', step: 'agreement', step_count: 1 }, + 'IdV: doc auth agreement submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'agreement', step_count: 1 }, + 'IdV: doc auth upload visited' => { flow_path: 'standard', step: 'upload', step_count: 1 }, + 'IdV: doc auth upload submitted' => { success: true, errors: {}, destination: :document_capture, flow_path: 'standard', step: 'upload', step_count: 1 }, + 'IdV: doc auth document_capture visited' => { flow_path: 'standard', step: 'document_capture', step_count: 1 }, + 'Frontend: IdV: front image added' => { 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard' }, + 'Frontend: IdV: document capture async upload encryption' => { 'success' => true, 'flow_path' => 'standard' }, + 'Frontend: IdV: back image added' => { 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard' }, + 'Frontend: IdV: document capture async upload submitted' => { 'success' => true, 'trace_id' => nil, 'status_code' => 200, 'flow_path' => 'standard' }, + 'IdV: doc auth image upload form submitted' => { success: true, errors: {}, attempts: nil, remaining_attempts: 3, user_id: nil, flow_path: 'standard' }, + 'IdV: doc auth image upload vendor submitted' => { success: true, flow_path: 'standard', attention_with_barcode: true, doc_auth_result: 'Attention' }, + 'IdV: doc auth verify_document_status submitted' => { success: true, flow_path: 'standard', step: 'verify_document_status', attention_with_barcode: true, doc_auth_result: 'Attention' }, + 'IdV: verify in person troubleshooting option clicked' => {}, + 'IdV: in person proofing location visited' => { 'flow_path' => 'standard' }, + 'IdV: in person proofing location submitted' => { 'flow_path' => 'standard', 'selected_location' => 'BALTIMORE' }, + 'IdV: in person proofing prepare visited' => { 'flow_path' => 'standard' }, + 'IdV: in person proofing prepare submitted' => { 'flow_path' => 'standard' }, + 'IdV: in person proofing state_id visited' => { step: 'state_id', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing state_id submitted' => { success: true, flow_path: 'standard', step: 'state_id', step_count: 1 }, + 'IdV: in person proofing address visited' => { step: 'address', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing address submitted' => { success: true, step: 'address', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing ssn visited' => { step: 'ssn', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing ssn submitted' => { success: true, step: 'ssn', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing verify visited' => { step: 'verify', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing verify submitted' => { success: true, step: 'verify', flow_path: 'standard', step_count: 1 }, + 'IdV: in person proofing verify_wait visited' => { flow_path: 'standard', step: 'verify_wait', step_count: 1 }, + 'IdV: in person proofing optional verify_wait submitted' => { success: true, step: 'verify_wait_step_show', address_edited: false, ssn_is_unique: true }, + 'IdV: phone of record visited' => {}, + 'IdV: phone confirmation form' => { success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202' }, + 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, context: { stages: [{ address: 'AddressMock' }] }, transaction_id: 'address-mock-transaction-id-123', timed_out: false }, new_phone_added: false }, + 'IdV: Phone OTP delivery Selection Visited' => {}, + 'IdV: Phone OTP Delivery Selection Submitted' => { success: true, otp_delivery_preference: 'sms' }, + 'IdV: phone confirmation otp sent' => { success: true, otp_delivery_preference: :sms, country_code: 'US', area_code: '202' }, + 'IdV: phone confirmation otp visited' => {}, + 'IdV: phone confirmation otp submitted' => { success: true, code_expired: false, code_matches: true, second_factor_attempts_count: 0, second_factor_locked_at: nil }, + 'IdV: review info visited' => {}, + 'IdV: review complete' => { success: true }, + 'IdV: final resolution' => { success: true }, + 'IdV: personal key visited' => {}, + 'Frontend: IdV: show personal key modal' => {}, + 'IdV: personal key submitted' => {}, + 'IdV: in person ready to verify visited' => {}, + } + end # rubocop:enable Layout/LineLength # Needed for enqueued_at in gpo_step @@ -88,6 +137,8 @@ before do allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + allow_any_instance_of(DocumentProofingJob).to receive(:build_analytics). + and_return(fake_analytics) end { @@ -149,4 +200,23 @@ end end end + + context 'in person path' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + + sign_in_and_2fa_user(user) + begin_in_person_proofing(user) + complete_all_in_person_proofing_steps(user) + complete_phone_step(user) + complete_review_step(user) + acknowledge_and_confirm_personal_key + end + + it 'records all of the events', allow_browser_log: true do + in_person_path_events.each do |event, attributes| + expect(fake_analytics).to have_logged_event(event, attributes) + end + end + end end