From ba927d9b6f329f3772d49ededda95e2b329274f1 Mon Sep 17 00:00:00 2001 From: Chad <36685920+chadbrokaw@users.noreply.github.com> Date: Thu, 19 Dec 2024 15:29:11 -0700 Subject: [PATCH] feat: DAH-3043 Analytics for Application Flow (#2448) * First pass * Improve analytics * fix: updates based on Tenley feedback * PR feedback --- .../account/AccountController.js.coffee | 5 ++ .../back-to-application-link.html.slim | 2 +- .../config/angularInitialize.js.coffee | 3 + .../config/angularRoutes.js.coffee | 6 +- .../shared/AnalyticsService.js.coffee | 60 +++++++++++++++++-- .../ShortFormApplicationController.js.coffee | 20 +++++++ .../ShortFormApplicationService.js.coffee | 3 + .../ShortFormNavigationService.js.coffee | 2 +- 8 files changed, 93 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/account/AccountController.js.coffee b/app/assets/javascripts/account/AccountController.js.coffee index ce5ad9d31..195ef2553 100644 --- a/app/assets/javascripts/account/AccountController.js.coffee +++ b/app/assets/javascripts/account/AccountController.js.coffee @@ -37,6 +37,11 @@ AccountController = ( $scope.passwordRegex = /^(?=.*[0-9])(?=.*[a-zA-Z])(.+){8,}$/ $scope.emailRegex = SharedService.emailRegex + $scope.logBackLinkClick = -> + path = window.location.pathname + listingId = path.split('/')[2] + AnalyticsService.trackApplicationStart(listingId || "", null, "Continue with application from save and exit") + $scope.accountForm = -> # pick up which ever one is defined (the other will be undefined) $scope.form.signIn || diff --git a/app/assets/javascripts/account/directives/back-to-application-link.html.slim b/app/assets/javascripts/account/directives/back-to-application-link.html.slim index 4f55a7973..34bd666d5 100644 --- a/app/assets/javascripts/account/directives/back-to-application-link.html.slim +++ b/app/assets/javascripts/account/directives/back-to-application-link.html.slim @@ -1,5 +1,5 @@ .back-link-container.small-only-text-center ng-if="rememberedShortFormState" - a.back-link ui-sref="{{rememberedShortFormState}}" + a.back-link(ui-sref="{{rememberedShortFormState}}" ng-click="logBackLinkClick()") span.ui-icon.ui-static.ui-medium.i-primary svg use xlink:href="#i-left" diff --git a/app/assets/javascripts/config/angularInitialize.js.coffee b/app/assets/javascripts/config/angularInitialize.js.coffee index 41c2ec8ea..21bd422f1 100644 --- a/app/assets/javascripts/config/angularInitialize.js.coffee +++ b/app/assets/javascripts/config/angularInitialize.js.coffee @@ -136,6 +136,9 @@ onConfirm: -> # fires only if user clicks 'ok' to leave page # reloads this stateChangeStart method with skipConfirm true + AnalyticsService.trackApplicationAbandon(ShortFormApplicationService.listing.listingID, null, "Leaving for " + toState.name) + $window.removeEventListener('unload', $rootScope.onUnload) + toParams.skipConfirm = true $state.go(toState.name, toParams) ) diff --git a/app/assets/javascripts/config/angularRoutes.js.coffee b/app/assets/javascripts/config/angularRoutes.js.coffee index 42de8276c..c2d1532d4 100644 --- a/app/assets/javascripts/config/angularRoutes.js.coffee +++ b/app/assets/javascripts/config/angularRoutes.js.coffee @@ -592,8 +592,8 @@ ] application: [ # 'listing' is part of the params so that application waits for listing (above) to resolve - '$q', '$stateParams', '$state', 'ShortFormApplicationService', 'AccountService', 'AutosaveService', 'listing' - ($q, $stateParams, $state, ShortFormApplicationService, AccountService, AutosaveService, listing) -> + '$q', '$stateParams', '$state', 'ShortFormApplicationService', 'AccountService', 'AutosaveService', 'AnalyticsService', 'listing' + ($q, $stateParams, $state, ShortFormApplicationService, AccountService, AutosaveService, AnalyticsService, listing) -> deferred = $q.defer() # if the user just clicked the language switcher, don't reload the whole route @@ -620,6 +620,8 @@ if ShortFormApplicationService.application.status == 'Submitted' # send them to their review page if the application is already submitted + # user id should always be present, but we are being cautious + AnalyticsService.trackApplicationAbandon(listing.Id, AccountService.loggedInUser?.id, "Application already submitted") $state.go('dahlia.short-form-review', {id: ShortFormApplicationService.application.id}) else if ShortFormApplicationService.application.autofill == true $state.go('dahlia.short-form-application.autofill-preview', {id: listing.Id, lang: $stateParams.lang}) diff --git a/app/assets/javascripts/shared/AnalyticsService.js.coffee b/app/assets/javascripts/shared/AnalyticsService.js.coffee index 5f4f805eb..a49653ebb 100644 --- a/app/assets/javascripts/shared/AnalyticsService.js.coffee +++ b/app/assets/javascripts/shared/AnalyticsService.js.coffee @@ -6,15 +6,22 @@ AnalyticsService = ($state) -> Service = {} Service.timer = {} + Service.resetProperties = { + # Any properties that should be reset on each event + } + Service.trackEvent = (event, properties) -> dataLayer = window.dataLayer || [] - unless properties.label + combinedProperties = Object.assign({}, Service.resetProperties, properties) + unless combinedProperties.label # by default, grab the end of the URL e.g. the "contact" from "/x/y/z/contact" current_path = _.first(_.last($state.current.url.split('/')).split('?')) - properties.label = current_path - properties.event = event - dataLayer.push(properties) + combinedProperties.label = current_path + combinedProperties.event = event + combinedProperties.event_timestamp = new Date().toISOString(); + dataLayer.push(combinedProperties) + # Tracks the current page as the user navigates Service.trackCurrentPage = -> ga = window.ga || () -> path = Service._currentHref() @@ -22,11 +29,15 @@ AnalyticsService = ($state) -> ga('set', 'page', path) ga('send', 'pageview') + # Fired when the entire application is loaded. Then events are tracked as diffs from this time as the user navigates Service.startTimer = (opts = {}) -> if opts.label Service.timer[opts.label] = {start: moment()} Service.timer[opts.label].variable = opts.variable if opts.variable + # Fired when the user clicks on the "Apply Online" button on a listing page. + # Events are tracked as diffs from the start time with specific keys as the user navigates + # Note that this is effectively deprecated as we no longer user the AngularJS listing page Service.trackTimerEvent = (category, label, variable = '') -> # once the timer has been cleared, we don't track it any more return unless Service.timer[label] @@ -40,23 +51,62 @@ AnalyticsService = ($state) -> Service.timer[label] = null Service.trackEvent('Timer Event', params) + Service.createApplicationTimer = (listingId) -> + currentTimeInMs = Date.now() + localStorage.setItem("Application_Analytics_#{listingId}", currentTimeInMs) + + Service.getApplicationDuration = (listingId) -> + currentTimeInMs = Date.now() + startTimeInMs = localStorage.getItem("Application_Analytics_#{listingId}") + + if startTimeInMs + localStorage.removeItem("Application_Analytics_#{listingId}") + return currentTimeInMs - startTimeInMs + + return null + + # Fired when the user creates an account, signs in, or requests a password change + # Fired when a user starts an autofilled application, continues a previous draft, or resets and starts a new application + # (This would be when the user decides not to start with an autofilled application) + # Also fired whenever a user completes any application page (No label is attached in this instance) Service.trackFormSuccess = (category, label = null) -> params = { category: category, action: 'Form Success', label: label } Service.trackEvent('Form Message', params) + Service.trackApplicationStart = (listingId, userId = null, origin = null) -> + params = { user_id: userId, listing_id: listingId, application_started_origin: origin } + Service.createApplicationTimer(listingId) + Service.trackEvent('application_started', params) + + Service.trackApplicationComplete = (listingId, userId = null, reason = null) -> + time_to_submit = Service.getApplicationDuration(listingId) + params = { user_id: userId, listing_id: listingId, time_to_submit: time_to_submit, reason: reason } + Service.trackEvent('application_completed', params) + + Service.trackApplicationAbandon = (listingId, userId = null, reason = null) -> + time_to_abandon = Service.getApplicationDuration(listingId) + params = { user_id: userId, listing_id: listingId, time_to_abandon: time_to_abandon, reason: reason } + Service.trackEvent('application_exited', params) + + # Distinct from trackFormFieldError, this function is called when there is a validation error on the form + # For example, if the user has an income that is out of bounds Service.trackFormError = (category, label = null, opts = {}) -> params = { category: category, action: 'Form Error', label: label } _.merge(params, opts) Service.trackEvent('Form Message', params) + # Fired when the user triggers a form field error, fieldId attached, error handler is global Service.trackFormFieldError = (category, fieldId = '', opts = {}) -> params = { category: category, action: 'Form Field Error', fieldId: fieldId } _.merge(params, opts) Service.trackEvent('Field Message', params) + # Fired when the user leaves the create account page without creating an account + # Fired when the user exits the application process Service.trackFormAbandon = (category) -> Service.trackEvent('Form Message', { category: category, action: 'Form Abandon' }) + # Fired when the user inputs a lotter number that is invalid Service.trackInvalidLotteryNumber = -> label = Service._currentHref() Service.trackEvent('Form Message', { @@ -65,9 +115,11 @@ AnalyticsService = ($state) -> label: label }) + # Fired when the user leaves an application as a result of a timeout Service.trackTimeout = (category) -> Service.trackEvent('Form Message', { category: category, action: 'Timeout' }) + # Fired when the user reached the my account page with a confirmed account Service.trackAccountCreation = -> Service.trackEvent('Form Message', { category: 'Accounts', action: 'Account Creation', label: 'Account Confirmation Success' }) diff --git a/app/assets/javascripts/short-form/ShortFormApplicationController.js.coffee b/app/assets/javascripts/short-form/ShortFormApplicationController.js.coffee index 9455a4463..853a4b8b5 100644 --- a/app/assets/javascripts/short-form/ShortFormApplicationController.js.coffee +++ b/app/assets/javascripts/short-form/ShortFormApplicationController.js.coffee @@ -83,9 +83,11 @@ ShortFormApplicationController = ( $scope.startAutofilledApp = -> AnalyticsService.trackFormSuccess('Application', 'Start with these details') + AnalyticsService.trackApplicationStart($scope.listing.Id, AccountService.loggedInUser?.id || null, 'Start with these details') $scope.go(ShortFormNavigationService.initialState()) $scope.trackContinuePreviousDraft = -> + AnalyticsService.trackApplicationStart($scope.listing.Id, AccountService.loggedInUser?.id || null, 'Continue with these details') AnalyticsService.trackFormSuccess('Application', 'Continue with these details') $scope.resetAndStartNewApp = -> @@ -103,16 +105,24 @@ ShortFormApplicationController = ( $scope.householdMembers = ShortFormApplicationService.householdMembers delete $scope.application.autofill AnalyticsService.trackFormSuccess('Application', 'Reset and start from scratch') + AnalyticsService.trackApplicationStart($scope.listing.Id, AccountService.loggedInUser?.id || null, 'Reset and start from scratch') $state.go(ShortFormNavigationService.initialState()) $scope.resetAndReplaceApp = ShortFormApplicationService.resetAndReplaceApp + $scope.onUnload = (e) -> + # We want to track the user's actual exit after confirming that they want to leave the page + # Note that this is not perfect, as this will also capture application reloads which doesn't necessarily constitute an abandonment + AnalyticsService.trackApplicationAbandon($scope.listing.Id, AccountService.loggedInUser?.id || null, "Generic Exit") + + $scope.atShortFormState = -> ShortFormApplicationService.isShortFormPage($state.current) if $scope.atShortFormState() && !$window.jasmine && !window.protractor # don't add this onbeforeunload inside of jasmine tests $window.addEventListener 'beforeunload', ShortFormApplicationService.onExit + $window.addEventListener 'unload', $scope.onUnload $scope.submitForm = -> form = $scope.form.applicationForm @@ -211,6 +221,7 @@ ShortFormApplicationController = ( $scope.applicant[fieldToDisable] = false $scope.beginApplication = (lang = 'en') -> + AnalyticsService.trackApplicationStart($scope.listing.Id, AccountService.loggedInUser?.id || null, 'Begin Application') if $scope.isCustomEducatorListing() ShortFormNavigationService.goToApplicationPage('dahlia.short-form-welcome.custom-educator-screening', {lang: lang}) else if $scope.listing.Reserved_community_type @@ -889,6 +900,7 @@ ShortFormApplicationController = ( .then( -> ShortFormNavigationService.isLoading(false) ShortFormNavigationService.goToApplicationPage('dahlia.short-form-application.confirmation') + AnalyticsService.trackApplicationComplete($scope.listing.Id, AccountService.loggedInUser?.id || null) ).catch( -> ShortFormNavigationService.isLoading(false) ) @@ -903,7 +915,9 @@ ShortFormApplicationController = ( # if redirecting to the React my-applications page, disable the "Leave site?" popup if $window.ACCOUNT_INFORMATION_PAGES_REACT is "true" $window.removeEventListener('beforeunload', ShortFormApplicationService.onExit) + $window.removeEventListener('unload', $scope.onUnload) + AnalyticsService.trackApplicationAbandon($scope.listing.Id, AccountService.loggedInUser.id, 'Logged In Save and Finish Later') # ShortFormNavigationService.isLoading(false) will happen after My Apps are loaded # go to my applications without tracking Form Success $scope.go('dahlia.my-applications', {skipConfirm: true}) @@ -912,6 +926,7 @@ ShortFormApplicationController = ( ) else ShortFormNavigationService.isLoading(false) + AnalyticsService.trackApplicationAbandon($scope.listing.Id, null, 'Logged Out Save and Finish Later') # go to Create Account without tracking Form Success $scope.go('dahlia.short-form-application.create-account') @@ -971,17 +986,22 @@ ShortFormApplicationController = ( if $scope.appIsSubmitted(previousApp) # My Applications page is now in React, prevent the "Leave Site?" popup when redirecting $window.removeEventListener('beforeunload', ShortFormApplicationService.onExit) + $window.removeEventListener('unload', $scope.onUnload) doubleSubmit = !! $scope.appIsSubmitted($scope.application) if $window.ACCOUNT_INFORMATION_PAGES_REACT is "true" currentUrl = window.location.origin newUrl = "#{currentUrl}/my-applications?" if previousApp.id + # user id should always be present, but we are being cautious + AnalyticsService.trackApplicationAbandon($scope.listing.Id, AccountService.loggedInUser?.id, 'Already Submitted') newUrl += "alreadySubmittedId=#{previousApp.id}" if doubleSubmit newUrl += "&" if doubleSubmit # As we rebuilt the My Applications page in React we were not able to figure out a way to trigger the Double Submit Modal. # We are leaving the code here both to document past behavior and to protect the application in case somehow the modal is triggered + # user id should always be present, but we are being cautious + AnalyticsService.trackApplicationAbandon($scope.listing.Id, AccountService.loggedInUser?.id, 'Double Submitted') newUrl += "doubleSubmit=true" window.location.href = newUrl diff --git a/app/assets/javascripts/short-form/ShortFormApplicationService.js.coffee b/app/assets/javascripts/short-form/ShortFormApplicationService.js.coffee index 0022c893c..08da50930 100644 --- a/app/assets/javascripts/short-form/ShortFormApplicationService.js.coffee +++ b/app/assets/javascripts/short-form/ShortFormApplicationService.js.coffee @@ -932,6 +932,9 @@ ShortFormApplicationService = ( # this will setup Service.application with the loaded data Service.resetApplicationData(formattedApp) + if !_.isEmpty(data.application) && !_.isEmpty(Service.application) && Service.application.status.match(/draft/i) && Service.application.id + AnalyticsService.trackApplicationStart(data?.application?.listingID, data?.application?.primaryApplicant?.webAppID, 'Continue with these details') + # one last step, reconcile any uploaded files with your saved member + preference data if !_.isEmpty(Service.application) && Service.application.status.match(/draft/i) Service.refreshPreferences('all') diff --git a/app/assets/javascripts/short-form/ShortFormNavigationService.js.coffee b/app/assets/javascripts/short-form/ShortFormNavigationService.js.coffee index fb24f889f..2adab5d9c 100644 --- a/app/assets/javascripts/short-form/ShortFormNavigationService.js.coffee +++ b/app/assets/javascripts/short-form/ShortFormNavigationService.js.coffee @@ -14,7 +14,7 @@ ShortFormNavigationService = ( Service.goToApplicationPage = (path, params) -> # Every time the user completes an application page, # we track that in GTM/GA as a form success. - AnalyticsService.trackFormSuccess('Application') + AnalyticsService.trackFormSuccess('Application Page View') if params $state.go(path, params) else