From 96bfcda2c7a1dc629b17bf2e62dd7425f5294f70 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Fri, 15 May 2020 09:21:30 -0400 Subject: [PATCH 1/4] Add Amplitude integration to front-end. --- frontend/lib/analytics/amplitude.ts | 158 ++++++++++++++++++ frontend/lib/app.tsx | 23 +++ frontend/lib/error-boundary.tsx | 2 + frontend/lib/forms/form-submitter.tsx | 15 ++ package.json | 1 + project/context_processors.py | 25 +++ project/justfix_environment.py | 4 + project/settings.py | 3 + project/settings_pytest.py | 1 + project/static/vendor/amplitude-6.2.0.min.js | 1 + .../static/vendor/amplitude-snippet.min.js | 26 +++ project/templates/index.html | 1 + yarn.lock | 5 + 13 files changed, 265 insertions(+) create mode 100644 frontend/lib/analytics/amplitude.ts create mode 100644 project/static/vendor/amplitude-6.2.0.min.js create mode 100644 project/static/vendor/amplitude-snippet.min.js diff --git a/frontend/lib/analytics/amplitude.ts b/frontend/lib/analytics/amplitude.ts new file mode 100644 index 000000000..5f0ab02a4 --- /dev/null +++ b/frontend/lib/analytics/amplitude.ts @@ -0,0 +1,158 @@ +import { AmplitudeClient, LogReturn } from "amplitude-js"; +import { SiteChoice } from "../../../common-data/site-choices"; +import { USStateChoice } from "../../../common-data/us-state-choices"; +import { SignupIntent } from "../../../common-data/signup-intent-choices"; +import { LeaseChoice } from "../../../common-data/lease-choices"; +import { AllSessionInfo } from "../queries/AllSessionInfo"; +import { isDeepEqual, assertNotNull } from "../util/util"; +import { ServerFormFieldError } from "../forms/form-errors"; + +export type JustfixAmplitudeUserProperties = { + city: string; + state: USStateChoice; + signupIntent: SignupIntent; + leaseType: LeaseChoice; + prefersLegacyApp: boolean | null; + isEmailVerified: boolean; + hasSentNorentLetter: boolean; + hasFiledEHPA: boolean; + issueCount: number; +}; + +export type JustfixAmplitudeClient = Omit< + AmplitudeClient, + "logEvent" | "setUserProperties" +> & { + logEvent( + event: "Page viewed", + data: { + pathname: string; + siteType: SiteChoice; + } + ): LogReturn; + + logEvent( + event: "Exception occurred", + data: { + errorString: string; + } + ): LogReturn; + + logEvent( + event: "Form submitted", + data: { + pathname: string, + formKind: string, + formId?: string, + redirect?: string, + errorMessages?: string[], + errorCodes?: string[], + } + ): LogReturn; + + setUserProperties(properties: Partial): void; +}; + +export interface JustfixAmplitudeAPI { + getInstance(): JustfixAmplitudeClient; +} + +declare global { + interface Window { + amplitude: JustfixAmplitudeAPI | undefined; + } +} + +export function getAmplitude(): JustfixAmplitudeClient | undefined { + if (typeof window === "undefined") return undefined; + return window.amplitude?.getInstance(); +} + +function getUserPropertiesFromSession( + s: AllSessionInfo +): Partial { + return { + city: + s.onboardingInfo?.city ?? + s.norentScaffolding?.city ?? + s.onboardingStep1?.borough, + state: + (s.onboardingInfo?.state as USStateChoice) ?? + s.norentScaffolding?.state ?? + (s.onboardingStep1?.borough ? "NY" : undefined), + signupIntent: s.onboardingInfo?.signupIntent, + leaseType: + (s.onboardingInfo?.leaseType as LeaseChoice) ?? + s.onboardingStep3?.leaseType ?? + undefined, + prefersLegacyApp: s.prefersLegacyApp, + isEmailVerified: s.isEmailVerified ?? undefined, + hasSentNorentLetter: !!s.norentLatestLetter, + hasFiledEHPA: s.emergencyHpActionSigningStatus === "SIGNED", + issueCount: s.issues.length + (s.customIssuesV2?.length ?? 0), + }; +} + +export function updateAmplitudeUserPropertiesOnSessionChange( + prevSession: AllSessionInfo, + session: AllSessionInfo +): boolean { + const prevUserProperties = getUserPropertiesFromSession(prevSession); + const userProperties = getUserPropertiesFromSession(session); + + if (isDeepEqual(prevUserProperties, userProperties)) { + return false; + } + + getAmplitude()?.setUserProperties(userProperties); + return true; +} + +export function trackLoginInAmplitude(s: AllSessionInfo) { + const userId = assertNotNull(s.userId).toString(); + getAmplitude()?.setUserId(userId); + getAmplitude()?.setUserProperties(getUserPropertiesFromSession(s)); +} + +export function trackLogoutInAmplitude(s: AllSessionInfo) { + // Note that we could also call `regenerateDeviceId()` after this + // to completely dissociate the user who is logging out from + // the newly anonymous user, but that would prevent us from + // being able to see that two users are actually using the same + // device, so we're not going to do that. + getAmplitude()?.setUserId(null); + getAmplitude()?.setUserProperties(getUserPropertiesFromSession(s)); +} + +export function logAmplitudeFormSubmission(options: { + pathname: string, + formKind: string, + formId?: string, + redirect?: string | null, + errors?: ServerFormFieldError[] +}) { + let errorMessages: string[]|undefined = undefined; + let errorCodes: string[]|undefined = undefined; + if (options.errors) { + errorMessages = []; + errorCodes = []; + for (let fieldErrors of options.errors) { + const { field } = fieldErrors; + for (let error of fieldErrors.extendedMessages) { + errorMessages.push(`${field}: ${error.message}`); + if (error.code) { + errorCodes.push(`${field}: ${error.code}`); + } + } + } + } + + getAmplitude()?.logEvent("Form submitted", { + pathname: options.pathname, + formKind: options.formKind, + formId: options.formId, + redirect: options.redirect ?? undefined, + errorMessages, + errorCodes, + }); +} diff --git a/frontend/lib/app.tsx b/frontend/lib/app.tsx index f6669c92f..8b2ab9701 100644 --- a/frontend/lib/app.tsx +++ b/frontend/lib/app.tsx @@ -39,6 +39,12 @@ import { getNorentJumpToTopOfPageRoutes } from "./norent/routes"; import { SupportedLocale } from "./i18n"; import { getGlobalSiteRoutes } from "./routes"; import { ensureNextRedirectIsHard } from "./browser-redirect"; +import { + getAmplitude, + updateAmplitudeUserPropertiesOnSessionChange, + trackLoginInAmplitude, + trackLogoutInAmplitude, +} from "./analytics/amplitude"; // Note that these don't need any special fallback loading screens // because they will never need to be dynamically loaded on the @@ -220,6 +226,13 @@ export class AppWithoutRouter extends React.Component< } } + trackAmplitudePageView(pathname = this.props.location.pathname) { + getAmplitude()?.logEvent("Page viewed", { + pathname, + siteType: this.props.server.siteType, + }); + } + handlePathnameChange( prevPathname: string, prevHash: string, @@ -229,6 +242,7 @@ export class AppWithoutRouter extends React.Component< ) { if (prevPathname !== pathname) { trackPageView(pathname); + this.trackAmplitudePageView(pathname); this.handleFocusDuringPathnameChange(prevPathname, pathname, hash); this.handleScrollPositionDuringPathnameChange( prevPathname, @@ -259,9 +273,12 @@ export class AppWithoutRouter extends React.Component< displayName: `${firstName || ""} (#${userId})`, }); } + trackLoginInAmplitude(this.state.session); } handleLogout() { + trackLogoutInAmplitude(this.state.session); + // We're not going to bother telling FullStory that the user logged out, // because we don't really want it associating the current user with a // brand-new anonymous user (as FullStory's priced plans have strict limits @@ -272,6 +289,7 @@ export class AppWithoutRouter extends React.Component< if (this.state.session.userId !== null) { this.handleLogin(); } + this.trackAmplitudePageView(); } componentDidUpdate(prevProps: AppPropsWithRouter, prevState: AppState) { @@ -281,6 +299,11 @@ export class AppWithoutRouter extends React.Component< } else { this.handleLogin(); } + } else { + updateAmplitudeUserPropertiesOnSessionChange( + prevState.session, + this.state.session + ); } if (prevState.session.csrfToken !== this.state.session.csrfToken) { this.gqlClient.csrfToken = this.state.session.csrfToken; diff --git a/frontend/lib/error-boundary.tsx b/frontend/lib/error-boundary.tsx index 3bc9d98f5..0386bdcf9 100644 --- a/frontend/lib/error-boundary.tsx +++ b/frontend/lib/error-boundary.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Helmet } from "react-helmet-async"; import { ga } from "./analytics/google-analytics"; +import { getAmplitude } from "./analytics/amplitude"; type ComponentStackInfo = { componentStack: string; @@ -104,6 +105,7 @@ export class ErrorBoundary extends React.Component< if (window.Rollbar) { window.Rollbar.error("ErrorBoundary caught an error!", error); } + getAmplitude()?.logEvent('Exception occurred', { errorString }); ga("send", "exception", { exDescription: errorString, exFatal: true, diff --git a/frontend/lib/forms/form-submitter.tsx b/frontend/lib/forms/form-submitter.tsx index 1cc16d95e..46d91e1f0 100644 --- a/frontend/lib/forms/form-submitter.tsx +++ b/frontend/lib/forms/form-submitter.tsx @@ -13,6 +13,7 @@ import { areFieldsEqual } from "./form-field-equality"; import { ga } from "../analytics/google-analytics"; import { HistoryBlocker } from "./history-blocker"; import { getDataLayer } from "../analytics/google-tag-manager"; +import { getAmplitude, logAmplitudeFormSubmission } from "../analytics/amplitude"; export type FormSubmitterProps< FormInput, @@ -219,6 +220,14 @@ export class FormSubmitterWithoutRouter< if (this.state.currentSubmissionId !== submissionId) return; if (output.errors.length) { trackFormErrors(output.errors); + if (this.props.formKind) { + logAmplitudeFormSubmission({ + pathname: this.props.location.pathname, + formKind: this.props.formKind, + formId: this.props.formId, + errors: output.errors + }); + } this.setState({ isLoading: false, latestOutput: output, @@ -264,6 +273,12 @@ export class FormSubmitterWithoutRouter< redirect || undefined ); if (this.props.formKind) { + logAmplitudeFormSubmission({ + pathname: this.props.location.pathname, + formKind: this.props.formKind, + formId: this.props.formId, + redirect: redirect + }); getDataLayer().push({ event: "jf.formSuccess", "jf.formKind": this.props.formKind, diff --git a/package.json b/package.json index 75c06d912..4e58ac6ef 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@loadable/component": "5.10.2", "@loadable/server": "5.10.2", "@loadable/webpack-plugin": "5.7.1", + "@types/amplitude-js": "^5.11.0", "@types/cheerio": "0.22.9", "@types/classnames": "2.2.6", "@types/glob": "7.1.1", diff --git a/project/context_processors.py b/project/context_processors.py index 0de6fbec7..1efbf00a0 100644 --- a/project/context_processors.py +++ b/project/context_processors.py @@ -225,3 +225,28 @@ def get_context(self): rollbar_snippet = RollbarSnippet() + + +class AmplitudeSnippet(JsSnippetContextProcessor): + SNIPPET_JS = MY_DIR / 'static' / 'vendor' / 'amplitude-snippet.min.js' + + template = SNIPPET_JS.read_text() + + csp_updates = { + 'CONNECT_SRC': 'https://api.amplitude.com', + } + + var_name = 'AMPLITUDE_SNIPPET' + + def is_enabled(self): + return settings.AMPLITUDE_API_KEY + + def get_context(self): + return { + 'AMPLITUDE_API_KEY': settings.AMPLITUDE_API_KEY, + 'amplitude_js_url': f'{settings.STATIC_URL}vendor/amplitude-6.2.0.min.js', + 'code_version': settings.GIT_INFO.get_version_str(), + } + + +amplitude_snippet = AmplitudeSnippet() diff --git a/project/justfix_environment.py b/project/justfix_environment.py index 844135527..834ff0655 100644 --- a/project/justfix_environment.py +++ b/project/justfix_environment.py @@ -112,6 +112,10 @@ class JustfixEnvironment(typed_environ.BaseEnvironment): # in the header metatags for the site. FACEBOOK_APP_ID: str = '' + # The Amplitude API key. If empty (the default), Amplitude integration + # will be disabled. + AMPLITUDE_API_KEY: str = '' + # An access token for Rollbar with the 'post_client_item' # scope. If empty (the default), Rollbar is disabled on # the client-side. diff --git a/project/settings.py b/project/settings.py index 527286234..7c110b645 100644 --- a/project/settings.py +++ b/project/settings.py @@ -148,6 +148,7 @@ 'project.context_processors.gtm_noscript_snippet', 'project.context_processors.facebook_pixel_snippet', 'project.context_processors.facebook_pixel_noscript_snippet', + 'project.context_processors.amplitude_snippet', 'project.context_processors.fullstory_snippet', 'project.context_processors.rollbar_snippet', ], @@ -386,6 +387,8 @@ FACEBOOK_PIXEL_ID = env.FACEBOOK_PIXEL_ID +AMPLITUDE_API_KEY = env.AMPLITUDE_API_KEY + FULLSTORY_ORG_ID = env.FULLSTORY_ORG_ID GIT_INFO = git.GitInfo.from_dir_or_env(BASE_DIR) diff --git a/project/settings_pytest.py b/project/settings_pytest.py index 45e4a08c3..d95b1f02e 100644 --- a/project/settings_pytest.py +++ b/project/settings_pytest.py @@ -29,6 +29,7 @@ GTM_CONTAINER_ID = '' FACEBOOK_PIXEL_ID = '' FACEBOOK_APP_ID = '' +AMPLITUDE_API_KEY = '' ROLLBAR_ACCESS_TOKEN = '' MAPBOX_ACCESS_TOKEN = '' NYCDB_DATABASE = None diff --git a/project/static/vendor/amplitude-6.2.0.min.js b/project/static/vendor/amplitude-6.2.0.min.js new file mode 100644 index 000000000..b7bef0230 --- /dev/null +++ b/project/static/vendor/amplitude-6.2.0.min.js @@ -0,0 +1 @@ +var amplitude=function(){"use strict";function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function i(e,t){for(var n=0;n>6|192):(t+=String.fromCharCode(i>>12|224),t+=String.fromCharCode(i>>6&63|128)),t+=String.fromCharCode(63&i|128))}return t},O=function(e){for(var t="",n=0,i=0,r=0,o=0;n>2,o=(3&t)<<4|(n=e.charCodeAt(c++))>>4,s=(15&n)<<2|(i=e.charCodeAt(c++))>>6,a=63&i,isNaN(n)?s=a=64:isNaN(i)&&(a=64),u=u+x._keyStr.charAt(r)+x._keyStr.charAt(o)+x._keyStr.charAt(s)+x._keyStr.charAt(a);return u},decode:function(e){try{if(window.btoa&&window.atob)return decodeURIComponent(escape(window.atob(e)))}catch(e){}return x._decode(e)},_decode:function(e){var t,n,i,r,o,s,a="",u=0;for(e=e.replace(/[^A-Za-z0-9\+\/\=]/g,"");u>4,n=(15&r)<<4|(o=x._keyStr.indexOf(e.charAt(u++)))>>2,i=(3&o)<<6|(s=x._keyStr.indexOf(e.charAt(u++))),a+=String.fromCharCode(t),64!==o&&(a+=String.fromCharCode(n)),64!==s&&(a+=String.fromCharCode(i));return a=O(a)}},N=Object.prototype.toString;function A(e){switch(N.call(e)){case"[object Date]":return"date";case"[object RegExp]":return"regexp";case"[object Arguments]":return"arguments";case"[object Array]":return"array";case"[object Error]":return"error"}return null===e?"null":void 0===e?"undefined":e!=e?"nan":e&&1===e.nodeType?"element":"undefined"!=typeof Buffer&&"function"==typeof Buffer.isBuffer&&Buffer.isBuffer(e)?"buffer":t(e=e.valueOf?e.valueOf():Object.prototype.valueOf.apply(e))}var e,C={DISABLE:0,ERROR:1,WARN:2,INFO:3},T=C.WARN,P={error:function(e){T>=C.ERROR&&R(e)},warn:function(e){T>=C.WARN&&R(e)},info:function(e){T>=C.INFO&&R(e)}},R=function(e){try{console.log("[Amplitude] "+e)}catch(e){}},q=function(e){return"string"===A(e)&&e.length>n?e.substring(0,n):e},D=function(e){var t=A(e);if("object"!==t)return P.error("Error: invalid properties format. Expecting Javascript object, received "+t+", ignoring"),{};if(Object.keys(e).length>a)return P.error("Error: too many properties (more than 1000), ignoring"),{};var n={};for(var i in e)if(e.hasOwnProperty(i)){var r=i,o=A(r);"string"!==o&&(r=String(r),P.warn("WARNING: Non-string property key, received type "+o+', coercing to string "'+r+'"'));var s=U(r,e[i]);null!==s&&(n[r]=s)}return n},j=["nan","function","arguments","regexp","element"],U=function e(t,n){var i=A(n);if(-1!==j.indexOf(i))P.warn('WARNING: Property key "'+t+'" with invalid value type '+i+", ignoring"),n=null;else if("undefined"===i)n=null;else if("error"===i)n=String(n),P.warn('WARNING: Property key "'+t+'" with value type error, coercing to '+n);else if("array"===i){for(var r=[],o=0;o>16)+(t>>16)+(n>>16)<<16|65535&n}function a(e,t,n,i,r,o){return d((s=d(d(t,e),d(i,o)))<<(a=r)|s>>>32-a,n);var s,a}function l(e,t,n,i,r,o,s){return a(t&n|~t&i,e,t,r,o,s)}function h(e,t,n,i,r,o,s){return a(t&i|n&~i,e,t,r,o,s)}function f(e,t,n,i,r,o,s){return a(t^n^i,e,t,r,o,s)}function v(e,t,n,i,r,o,s){return a(n^(t|~i),e,t,r,o,s)}function u(e,t){var n,i,r,o,s;e[t>>5]|=128<>>9<<4)]=t;var a=1732584193,u=-271733879,c=-1732584194,p=271733878;for(n=0;n>5]>>>t%32&255);return n}function p(e){var t,n=[];for(n[(e.length>>2)-1]=void 0,t=0;t>5]|=(255&e.charCodeAt(t/8))<>>4&15)+i.charAt(15&t);return r}function n(e){return unescape(encodeURIComponent(e))}function r(e){return c(u(p(t=n(e)),8*t.length));var t}function o(e,t){return function(e,t){var n,i,r=p(e),o=[],s=[];for(o[15]=s[15]=void 0,16o.options.sessionTimeout)&&(o.options.unsetParamsReferrerOnNewSession&&o._unsetUTMParams(),o._newSession=!0,o._sessionId=t,o.options.saveParamsReferrerOncePerSession&&o._trackParamsAndReferrer()),o.options.saveParamsReferrerOncePerSession||o._trackParamsAndReferrer(),o.options.saveEvents&&(Pe(o._unsentEvents),Pe(o._unsentIdentifys)),o._lastEventTime=t,Ue(o),o._pendingReadStorage=!1,o._sendEventsIfReady();for(var n=0;n=this.options.eventUploadThreshold?(this.sendEvents(),!0):(this._updateScheduled||(this._updateScheduled=!0,setTimeout(function(){this._updateScheduled=!1,this.sendEvents()}.bind(this),this.options.eventUploadPeriodMillis)),!1):(this.sendEvents(),!0))},Te.prototype._getFromStorage=function(e,t){return e.getItem(t+this._storageSuffix)},Te.prototype._setInStorage=function(e,t,n){e.setItem(t+this._storageSuffix,n)};var qe=function(e){if(e._useOldCookie){var t=e.cookieStorage.get(e._oldCookiename);"object"!==A(t)||je(e,t)}else{var n=e._metadataStorage.load();"object"===A(n)&&je(e,n)}},De=function(e){var t=e.cookieStorage.get(e._oldCookiename);"object"===A(t)&&(je(e,t),Ue(e))},je=function(e,t){t.deviceId&&(e.options.deviceId=t.deviceId),t.userId&&(e.options.userId=t.userId),null!==t.optOut&&void 0!==t.optOut&&!1!==t.optOut&&(e.options.optOut=t.optOut),t.sessionId&&(e._sessionId=parseInt(t.sessionId,10)),t.lastEventTime&&(e._lastEventTime=parseInt(t.lastEventTime,10)),t.eventId&&(e._eventId=parseInt(t.eventId,10)),t.identifyId&&(e._identifyId=parseInt(t.identifyId,10)),t.sequenceNumber&&(e._sequenceNumber=parseInt(t.sequenceNumber,10))},Ue=function(e){var t={deviceId:e.options.deviceId,userId:e.options.userId,optOut:e.options.optOut,sessionId:e._sessionId,lastEventTime:e._lastEventTime,eventId:e._eventId,identifyId:e._identifyId,sequenceNumber:e._sequenceNumber};e._useOldCookie?e.cookieStorage.set(e.options.cookieName+e._storageSuffix,t):e._metadataStorage.save(t)};Te.prototype._initUtmData=function(e,t){e=e||this._getUrlParams(),t=t||this.cookieStorage.get("__utmz");var n,i,r,o,s,a,u,c,p,d,l,h=(i=e,r=(n=t)?"?"+n.split(".").slice(-1)[0].replace(/\|/g,"&"):"",s=(o=function(e,t,n,i){return G(e,t)||G(n,i)})(w,i,"utmcsr",r),a=o(b,i,"utmcmd",r),u=o(I,i,"utmccn",r),c=o(S,i,"utmctr",r),p=o(k,i,"utmcct",r),d={},(l=function(e,t){B(t)||(d[e]=t)})(w,s),l(b,a),l(I,u),l(S,c),l(k,p),d);Me(this,h)},Te.prototype._unsetUTMParams=function(){var e=new le;e.unset(_),e.unset(w),e.unset(b),e.unset(I),e.unset(S),e.unset(k),this.identify(e)};var Me=function(e,t){if("object"===A(t)&&0!==Object.keys(t).length){var n=new le;for(var i in t)t.hasOwnProperty(i)&&(n.setOnce("initial_"+i,t[i]),n.set(i,t[i]));e.identify(n)}};Te.prototype._getReferrer=function(){return document.referrer},Te.prototype._getUrlParams=function(){return location.search},Te.prototype._saveGclid=function(e){var t=G("gclid",e);B(t)||Me(this,{gclid:t})},Te.prototype._getDeviceIdFromUrlParam=function(e){return G(v,e)},Te.prototype._getReferringDomain=function(e){if(B(e))return null;var t=e.split("/");return 3<=t.length?t[2]:null},Te.prototype._saveReferrer=function(e){if(!B(e)){var t={referrer:e,referring_domain:this._getReferringDomain(e)};Me(this,t)}},Te.prototype.saveEvents=function(){try{var e=JSON.stringify(this._unsentEvents.map(function(e){return e.event}));this._setInStorage(ue,this.options.unsentKey,e)}catch(e){}try{var t=JSON.stringify(this._unsentIdentifys.map(function(e){return e.event}));this._setInStorage(ue,this.options.unsentIdentifyKey,t)}catch(e){}},Te.prototype.setDomain=function(e){if(this._shouldDeferCall())return this._q.push(["setDomain"].concat(Array.prototype.slice.call(arguments,0)));if(L(e,"domain","string"))try{this.cookieStorage.options({expirationDays:this.options.cookieExpiration,secure:this.options.secureCookie,domain:e,sameSite:this.options.sameSiteCookie}),this.options.domain=this.cookieStorage.options().domain,qe(this),Ue(this)}catch(e){z.error(e)}},Te.prototype.setUserId=function(e){if(this._shouldDeferCall())return this._q.push(["setUserId"].concat(Array.prototype.slice.call(arguments,0)));try{this.options.userId=null!=e&&""+e||null,Ue(this)}catch(e){z.error(e)}},Te.prototype.setGroup=function(e,t){if(this._shouldDeferCall())return this._q.push(["setGroup"].concat(Array.prototype.slice.call(arguments,0)));if(this._apiKeySet("setGroup()")&&L(e,"groupType","string")&&!B(e)){var n={};n[e]=t;var i=(new le).set(e,t);this._logEvent(m,null,null,i.userPropertiesOperations,n,null,null,null)}},Te.prototype.setOptOut=function(e){if(this._shouldDeferCall())return this._q.push(["setOptOut"].concat(Array.prototype.slice.call(arguments,0)));if(L(e,"enable","boolean"))try{this.options.optOut=e,Ue(this)}catch(e){z.error(e)}},Te.prototype.setSessionId=function(e){if(L(e,"sessionId","number"))try{this._sessionId=e,Ue(this)}catch(e){z.error(e)}},Te.prototype.resetSessionId=function(){this.setSessionId((new Date).getTime())},Te.prototype.regenerateDeviceId=function(){if(this._shouldDeferCall())return this._q.push(["regenerateDeviceId"].concat(Array.prototype.slice.call(arguments,0)));this.setDeviceId(Ne())},Te.prototype.setDeviceId=function(e){if(this._shouldDeferCall())return this._q.push(["setDeviceId"].concat(Array.prototype.slice.call(arguments,0)));if(L(e,"deviceId","string"))try{B(e)||(this.options.deviceId=""+e,Ue(this))}catch(e){z.error(e)}},Te.prototype.setUserProperties=function(e){if(this._shouldDeferCall())return this._q.push(["setUserProperties"].concat(Array.prototype.slice.call(arguments,0)));if(this._apiKeySet("setUserProperties()")&&L(e,"userProperties","object")){var t=F(W(e));if(0!==Object.keys(t).length){var n=new le;for(var i in t)t.hasOwnProperty(i)&&n.set(i,t[i]);this.identify(n)}}},Te.prototype.clearUserProperties=function(){if(this._shouldDeferCall())return this._q.push(["clearUserProperties"].concat(Array.prototype.slice.call(arguments,0)));if(this._apiKeySet("clearUserProperties()")){var e=new le;e.clearAll(),this.identify(e)}};var Ke=function(e,t){for(var n=0;nthis.options.sessionTimeout)&&(this._sessionId=p),this._lastEventTime=p,Ue(this);var d=this._ua.browser.name,l=this._ua.browser.major,h=this._ua.device.model,f=this._ua.device.vendor;i=i||{},n=g({},n||{},g({},this._apiPropertiesTrackingOptions)),t=t||{},r=r||{},o=o||{};var v={device_id:this.options.deviceId,user_id:this.options.userId,timestamp:p,event_id:u,session_id:this._sessionId||-1,event_type:e,version_name:ze(this,"version_name")&&this.options.versionName||null,platform:ze(this,"platform")?this.options.platform:null,os_name:ze(this,"os_name")&&d||null,os_version:ze(this,"os_version")&&l||null,device_model:ze(this,"device_model")&&h||null,device_manufacturer:ze(this,"device_manufacturer")&&f||null,language:ze(this,"language")?this.options.language:null,carrier:(ze(this,"carrier"),null),api_properties:n,event_properties:F(W(t)),user_properties:F(W(i)),uuid:function e(t){return t?(t^16*Math.random()>>t/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,e)}(),library:{name:"amplitude-js",version:Ae},sequence_number:c,groups:F(V(r)),group_properties:F(W(o)),user_agent:this._userAgent};return e===m||e===y?(this._unsentIdentifys.push({event:v,callback:a}),this._limitEventsQueued(this._unsentIdentifys)):(this._unsentEvents.push({event:v,callback:a}),this._limitEventsQueued(this._unsentEvents)),this.options.saveEvents&&this.saveEvents(),this._sendEventsIfReady(a),u}catch(e){z.error(e)}else"function"===A(a)&&a(0,"No request sent",{reason:"Missing eventType"})};var ze=function(e,t){return!!e.options.trackingOptions[t]},Be=function(e){for(var t=["city","country","dma","ip_address","region"],n={},i=0;ithis.options.savedMaxCount&&e.splice(0,e.length-this.options.savedMaxCount)},Te.prototype.logEvent=function(e,t,n){return this._shouldDeferCall()?this._q.push(["logEvent"].concat(Array.prototype.slice.call(arguments,0))):this.logEventWithTimestamp(e,t,null,n)},Te.prototype.logEventWithTimestamp=function(e,t,n,i){return this._shouldDeferCall()?this._q.push(["logEventWithTimestamp"].concat(Array.prototype.slice.call(arguments,0))):this._apiKeySet("logEvent()")?L(e,"eventType","string")?B(e)?("function"===A(i)&&i(0,"No request sent",{reason:"Missing eventType"}),-1):this._logEvent(e,t,null,null,null,null,n,i):("function"===A(i)&&i(0,"No request sent",{reason:"Invalid type for eventType"}),-1):("function"===A(i)&&i(0,"No request sent",{reason:"API key not set"}),-1)},Te.prototype.logEventWithGroups=function(e,t,n,i){return this._shouldDeferCall()?this._q.push(["logEventWithGroups"].concat(Array.prototype.slice.call(arguments,0))):this._apiKeySet("logEventWithGroups()")?L(e,"eventType","string")?this._logEvent(e,t,null,null,n,null,null,i):("function"===A(i)&&i(0,"No request sent",{reason:"Invalid type for eventType"}),-1):("function"===A(i)&&i(0,"No request sent",{reason:"API key not set"}),-1)};var Ge=function(e){return!isNaN(parseFloat(e))&&isFinite(e)};Te.prototype.logRevenueV2=function(e){if(this._shouldDeferCall())return this._q.push(["logRevenueV2"].concat(Array.prototype.slice.call(arguments,0)));if(this._apiKeySet("logRevenueV2()"))if("object"===A(e)&&e.hasOwnProperty("_q")&&(e=Ke(new Ee,e)),e instanceof Ee){if(e&&e._isValidRevenue())return this.logEvent(s,e._toJSONObject())}else z.error("Invalid revenue input type. Expected Revenue object but saw "+A(e))},Te.prototype.logRevenue=function(e,t,n){return this._shouldDeferCall()?this._q.push(["logRevenue"].concat(Array.prototype.slice.call(arguments,0))):this._apiKeySet("logRevenue()")&&Ge(e)&&(void 0===t||Ge(t))?this._logEvent(s,{},{productId:n,special:"revenue_amount",quantity:t||1,price:e},null,null,null,null,null):-1},Te.prototype.removeEvents=function(e,t,n,i){Fe(this,"_unsentEvents",e,n,i),Fe(this,"_unsentIdentifys",t,n,i)};var Fe=function(e,t,n,i,r){if(!(n<0)){for(var o=[],s=0;sn?o.push(a):a.callback&&a.callback(i,r)}e[t]=o}};Te.prototype.sendEvents=function(){if(this._apiKeySet("sendEvents()")){if(this.options.optOut)this.removeEvents(1/0,1/0,0,"No request sent",{reason:"Opt out is set to true"});else if(0!==this._unsentCount()&&!this._sending){this._sending=!0;var e=(this.options.forceHttps?"https":"https:"===window.location.protocol?"https":"http")+"://"+this.options.apiEndpoint,n=Math.min(this._unsentCount(),this.options.uploadBatchSize),t=this._mergeEventsAndIdentifys(n),i=t.maxEventId,r=t.maxIdentifyId,o=JSON.stringify(t.eventsToSend.map(function(e){return e.event})),s=(new Date).getTime(),a={client:this.options.apiKey,e:o,v:c,upload_time:s,checksum:ve(c+this.options.apiKey+o+s)},u=this;new ke(e,a).send(function(e,t){u._sending=!1;try{200===e&&"success"===t?(u.removeEvents(i,r,e,t),u.options.saveEvents&&u.saveEvents(),u._sendEventsIfReady()):413===e&&(1===u.options.uploadBatchSize&&u.removeEvents(i,r,e,t),u.options.uploadBatchSize=Math.ceil(n/2),u.sendEvents())}catch(e){}})}}else this.removeEvents(1/0,1/0,0,"No request sent",{reason:"API key not set"})},Te.prototype._mergeEventsAndIdentifys=function(e){for(var t=[],n=0,i=-1,r=0,o=-1;t.length=this._unsentIdentifys.length,u=n>=this._unsentEvents.length;if(u&&a){z.error("Merging Events and Identifys, less events and identifys than expected");break}a?i=(s=this._unsentEvents[n++]).event.event_id:u?o=(s=this._unsentIdentifys[r++]).event.event_id:!("sequence_number"in this._unsentEvents[n].event)||this._unsentEvents[n].event.sequence_number {% endif %} diff --git a/yarn.lock b/yarn.lock index 7f2723209..632f6966a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1533,6 +1533,11 @@ "@testing-library/dom" "^7.2.2" "@types/testing-library__react" "^10.0.1" +"@types/amplitude-js@^5.11.0": + version "5.11.0" + resolved "https://registry.yarnpkg.com/@types/amplitude-js/-/amplitude-js-5.11.0.tgz#2d45f43e701d52407a5b57547a42d321c6d3c1b4" + integrity sha512-d+ELEiXtU8H/wVtmGPxTiPuyZW5HWEdKrOWGO99Y1eOvJuSLw3g/b6xlB9+snUOW5P8AYDhCHh8TzT6Vsz8jzQ== + "@types/anymatch@*": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" From aabef495f1167cddd632c69acbac133ca9eaf3fb Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Fri, 15 May 2020 09:32:28 -0400 Subject: [PATCH 2/4] Fix lint errors --- frontend/lib/analytics/amplitude.ts | 26 +++++++++++++------------- frontend/lib/error-boundary.tsx | 2 +- frontend/lib/forms/form-submitter.tsx | 6 +++--- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/frontend/lib/analytics/amplitude.ts b/frontend/lib/analytics/amplitude.ts index 5f0ab02a4..cae653b1b 100644 --- a/frontend/lib/analytics/amplitude.ts +++ b/frontend/lib/analytics/amplitude.ts @@ -41,12 +41,12 @@ export type JustfixAmplitudeClient = Omit< logEvent( event: "Form submitted", data: { - pathname: string, - formKind: string, - formId?: string, - redirect?: string, - errorMessages?: string[], - errorCodes?: string[], + pathname: string; + formKind: string; + formId?: string; + redirect?: string; + errorMessages?: string[]; + errorCodes?: string[]; } ): LogReturn; @@ -125,14 +125,14 @@ export function trackLogoutInAmplitude(s: AllSessionInfo) { } export function logAmplitudeFormSubmission(options: { - pathname: string, - formKind: string, - formId?: string, - redirect?: string | null, - errors?: ServerFormFieldError[] + pathname: string; + formKind: string; + formId?: string; + redirect?: string | null; + errors?: ServerFormFieldError[]; }) { - let errorMessages: string[]|undefined = undefined; - let errorCodes: string[]|undefined = undefined; + let errorMessages: string[] | undefined = undefined; + let errorCodes: string[] | undefined = undefined; if (options.errors) { errorMessages = []; errorCodes = []; diff --git a/frontend/lib/error-boundary.tsx b/frontend/lib/error-boundary.tsx index 0386bdcf9..30b48d826 100644 --- a/frontend/lib/error-boundary.tsx +++ b/frontend/lib/error-boundary.tsx @@ -105,7 +105,7 @@ export class ErrorBoundary extends React.Component< if (window.Rollbar) { window.Rollbar.error("ErrorBoundary caught an error!", error); } - getAmplitude()?.logEvent('Exception occurred', { errorString }); + getAmplitude()?.logEvent("Exception occurred", { errorString }); ga("send", "exception", { exDescription: errorString, exFatal: true, diff --git a/frontend/lib/forms/form-submitter.tsx b/frontend/lib/forms/form-submitter.tsx index 46d91e1f0..67993863a 100644 --- a/frontend/lib/forms/form-submitter.tsx +++ b/frontend/lib/forms/form-submitter.tsx @@ -13,7 +13,7 @@ import { areFieldsEqual } from "./form-field-equality"; import { ga } from "../analytics/google-analytics"; import { HistoryBlocker } from "./history-blocker"; import { getDataLayer } from "../analytics/google-tag-manager"; -import { getAmplitude, logAmplitudeFormSubmission } from "../analytics/amplitude"; +import { logAmplitudeFormSubmission } from "../analytics/amplitude"; export type FormSubmitterProps< FormInput, @@ -225,7 +225,7 @@ export class FormSubmitterWithoutRouter< pathname: this.props.location.pathname, formKind: this.props.formKind, formId: this.props.formId, - errors: output.errors + errors: output.errors, }); } this.setState({ @@ -277,7 +277,7 @@ export class FormSubmitterWithoutRouter< pathname: this.props.location.pathname, formKind: this.props.formKind, formId: this.props.formId, - redirect: redirect + redirect: redirect, }); getDataLayer().push({ event: "jf.formSuccess", From 5628febf5b975932f36de412302027e391f360d4 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Fri, 15 May 2020 11:24:51 -0400 Subject: [PATCH 3/4] Use very granular event names. --- frontend/lib/analytics/amplitude.ts | 106 +++++++++++++++++++--------- frontend/lib/app.tsx | 13 +--- frontend/lib/error-boundary.tsx | 2 - frontend/lib/justfix-routes.ts | 3 + frontend/lib/norent/routes.ts | 5 +- frontend/lib/routes.ts | 5 +- 6 files changed, 85 insertions(+), 49 deletions(-) diff --git a/frontend/lib/analytics/amplitude.ts b/frontend/lib/analytics/amplitude.ts index cae653b1b..0313f0d4f 100644 --- a/frontend/lib/analytics/amplitude.ts +++ b/frontend/lib/analytics/amplitude.ts @@ -1,11 +1,15 @@ -import { AmplitudeClient, LogReturn } from "amplitude-js"; +import { AmplitudeClient } from "amplitude-js"; import { SiteChoice } from "../../../common-data/site-choices"; import { USStateChoice } from "../../../common-data/us-state-choices"; import { SignupIntent } from "../../../common-data/signup-intent-choices"; import { LeaseChoice } from "../../../common-data/lease-choices"; import { AllSessionInfo } from "../queries/AllSessionInfo"; -import { isDeepEqual, assertNotNull } from "../util/util"; +import { isDeepEqual } from "../util/util"; import { ServerFormFieldError } from "../forms/form-errors"; +import { getGlobalSiteRoutes } from "../routes"; +import { getGlobalAppServerInfo, AppServerInfo } from "../app-context"; +import { LocaleChoice } from "../../../common-data/locale-choices"; +import i18n from "../i18n"; export type JustfixAmplitudeUserProperties = { city: string; @@ -19,37 +23,24 @@ export type JustfixAmplitudeUserProperties = { issueCount: number; }; +type PageInfo = { + pathname: string; + locale: LocaleChoice; + siteType: SiteChoice; +}; + +type FormSubmissionEventData = PageInfo & { + formKind: string; + formId?: string; + redirect?: string; + errorMessages?: string[]; + errorCodes?: string[]; +}; + export type JustfixAmplitudeClient = Omit< AmplitudeClient, - "logEvent" | "setUserProperties" + "setUserProperties" > & { - logEvent( - event: "Page viewed", - data: { - pathname: string; - siteType: SiteChoice; - } - ): LogReturn; - - logEvent( - event: "Exception occurred", - data: { - errorString: string; - } - ): LogReturn; - - logEvent( - event: "Form submitted", - data: { - pathname: string; - formKind: string; - formId?: string; - redirect?: string; - errorMessages?: string[]; - errorCodes?: string[]; - } - ): LogReturn; - setUserProperties(properties: Partial): void; }; @@ -63,7 +54,7 @@ declare global { } } -export function getAmplitude(): JustfixAmplitudeClient | undefined { +function getAmplitude(): JustfixAmplitudeClient | undefined { if (typeof window === "undefined") return undefined; return window.amplitude?.getInstance(); } @@ -109,7 +100,9 @@ export function updateAmplitudeUserPropertiesOnSessionChange( } export function trackLoginInAmplitude(s: AllSessionInfo) { - const userId = assertNotNull(s.userId).toString(); + // This will make it easier to distinguish our user IDs from + // Amplitude ones, which are just really large numbers. + const userId = `justfix:${s.userId}`; getAmplitude()?.setUserId(userId); getAmplitude()?.setUserProperties(getUserPropertiesFromSession(s)); } @@ -124,6 +117,45 @@ export function trackLogoutInAmplitude(s: AllSessionInfo) { getAmplitude()?.setUserProperties(getUserPropertiesFromSession(s)); } +const FRIENDLY_SITE_NAMES: { [k in SiteChoice]: string } = { + JUSTFIX: "justfix.nyc", + NORENT: "norent.org", +}; + +function getPageInfo(pathname: string): PageInfo { + const serverInfo = getGlobalAppServerInfo(); + return { + pathname: unlocalizePathname(pathname, serverInfo), + locale: i18n.locale, + siteType: serverInfo.siteType, + }; +} + +function unlocalizePathname( + pathname: string, + serverInfo: AppServerInfo +): string { + const { prefix } = getGlobalSiteRoutes(serverInfo).locale; + return pathname.startsWith(prefix + "/") + ? pathname.substring(prefix.length) + : pathname; +} + +function getFriendlyAmplitudePagePath( + pathname: string, + serverInfo = getGlobalAppServerInfo() +): string { + const siteName = FRIENDLY_SITE_NAMES[serverInfo.siteType]; + pathname = unlocalizePathname(pathname, serverInfo); + return `${siteName}${pathname}`; +} + +export function logAmplitudePageView(pathname: string) { + const data: PageInfo = getPageInfo(pathname); + const eventName = `Viewed page ${getFriendlyAmplitudePagePath(pathname)}`; + getAmplitude()?.logEvent(eventName, data); +} + export function logAmplitudeFormSubmission(options: { pathname: string; formKind: string; @@ -147,12 +179,16 @@ export function logAmplitudeFormSubmission(options: { } } - getAmplitude()?.logEvent("Form submitted", { - pathname: options.pathname, + const formName = options.formId ? `Form ${options.formId}` : "Form"; + const friendlyPath = getFriendlyAmplitudePagePath(options.pathname); + const eventName = `Submitted ${formName} on ${friendlyPath}`; + const data: FormSubmissionEventData = { + ...getPageInfo(options.pathname), formKind: options.formKind, formId: options.formId, redirect: options.redirect ?? undefined, errorMessages, errorCodes, - }); + }; + getAmplitude()?.logEvent(eventName, data); } diff --git a/frontend/lib/app.tsx b/frontend/lib/app.tsx index 8b2ab9701..abd2bb27c 100644 --- a/frontend/lib/app.tsx +++ b/frontend/lib/app.tsx @@ -40,10 +40,10 @@ import { SupportedLocale } from "./i18n"; import { getGlobalSiteRoutes } from "./routes"; import { ensureNextRedirectIsHard } from "./browser-redirect"; import { - getAmplitude, updateAmplitudeUserPropertiesOnSessionChange, trackLoginInAmplitude, trackLogoutInAmplitude, + logAmplitudePageView, } from "./analytics/amplitude"; // Note that these don't need any special fallback loading screens @@ -226,13 +226,6 @@ export class AppWithoutRouter extends React.Component< } } - trackAmplitudePageView(pathname = this.props.location.pathname) { - getAmplitude()?.logEvent("Page viewed", { - pathname, - siteType: this.props.server.siteType, - }); - } - handlePathnameChange( prevPathname: string, prevHash: string, @@ -242,7 +235,7 @@ export class AppWithoutRouter extends React.Component< ) { if (prevPathname !== pathname) { trackPageView(pathname); - this.trackAmplitudePageView(pathname); + logAmplitudePageView(pathname); this.handleFocusDuringPathnameChange(prevPathname, pathname, hash); this.handleScrollPositionDuringPathnameChange( prevPathname, @@ -289,7 +282,7 @@ export class AppWithoutRouter extends React.Component< if (this.state.session.userId !== null) { this.handleLogin(); } - this.trackAmplitudePageView(); + logAmplitudePageView(this.props.location.pathname); } componentDidUpdate(prevProps: AppPropsWithRouter, prevState: AppState) { diff --git a/frontend/lib/error-boundary.tsx b/frontend/lib/error-boundary.tsx index 30b48d826..3bc9d98f5 100644 --- a/frontend/lib/error-boundary.tsx +++ b/frontend/lib/error-boundary.tsx @@ -1,7 +1,6 @@ import React from "react"; import { Helmet } from "react-helmet-async"; import { ga } from "./analytics/google-analytics"; -import { getAmplitude } from "./analytics/amplitude"; type ComponentStackInfo = { componentStack: string; @@ -105,7 +104,6 @@ export class ErrorBoundary extends React.Component< if (window.Rollbar) { window.Rollbar.error("ErrorBoundary caught an error!", error); } - getAmplitude()?.logEvent("Exception occurred", { errorString }); ga("send", "exception", { exDescription: errorString, exFatal: true, diff --git a/frontend/lib/justfix-routes.ts b/frontend/lib/justfix-routes.ts index 4783f4d4c..3e62ddd0e 100644 --- a/frontend/lib/justfix-routes.ts +++ b/frontend/lib/justfix-routes.ts @@ -232,6 +232,9 @@ export type LocalizedRouteInfo = ReturnType; function createLocalizedRouteInfo(prefix: string) { return { + /** The locale prefix, e.g. `/en`. */ + [ROUTE_PREFIX]: prefix, + /** The login page. */ login: `${prefix}/login`, diff --git a/frontend/lib/norent/routes.ts b/frontend/lib/norent/routes.ts index dd5bbed4b..26b95e82d 100644 --- a/frontend/lib/norent/routes.ts +++ b/frontend/lib/norent/routes.ts @@ -1,4 +1,4 @@ -import { createRoutesForSite } from "../util/route-util"; +import { createRoutesForSite, ROUTE_PREFIX } from "../util/route-util"; import { createDevRouteInfo } from "../dev/routes"; import { createLetterStaticPageRouteInfo } from "../static-page/routes"; import { createNorentLetterBuilderRouteInfo } from "./letter-builder/routes"; @@ -10,6 +10,9 @@ import { createNorentLetterBuilderRouteInfo } from "./letter-builder/routes"; */ function createLocalizedRouteInfo(prefix: string) { return { + /** The locale prefix, e.g. `/en`. */ + [ROUTE_PREFIX]: prefix, + /** The home page. */ home: `${prefix}/`, diff --git a/frontend/lib/routes.ts b/frontend/lib/routes.ts index 27e55c844..746421050 100644 --- a/frontend/lib/routes.ts +++ b/frontend/lib/routes.ts @@ -1,11 +1,14 @@ import { DevRouteInfo } from "./dev/routes"; -import { RouteInfo } from "./util/route-util"; +import { RouteInfo, ROUTE_PREFIX } from "./util/route-util"; import { getGlobalAppServerInfo, AppServerInfo } from "./app-context"; import { default as JustfixRoutes } from "./justfix-routes"; import { NorentRoutes } from "./norent/routes"; /** Common localized routes all our sites support. */ type CommonLocalizedSiteRoutes = { + /** The locale prefix. */ + [ROUTE_PREFIX]: string; + /** The site home page. */ home: string; }; From f07f32f84f46196bae6df81f21f619a94f987b8b Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Fri, 15 May 2020 12:58:44 -0400 Subject: [PATCH 4/4] Use slightly less granular event names. --- frontend/lib/analytics/amplitude.ts | 67 +++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/frontend/lib/analytics/amplitude.ts b/frontend/lib/analytics/amplitude.ts index 0313f0d4f..ad9a8d19c 100644 --- a/frontend/lib/analytics/amplitude.ts +++ b/frontend/lib/analytics/amplitude.ts @@ -10,6 +10,8 @@ import { getGlobalSiteRoutes } from "../routes"; import { getGlobalAppServerInfo, AppServerInfo } from "../app-context"; import { LocaleChoice } from "../../../common-data/locale-choices"; import i18n from "../i18n"; +import JustfixRoutes from "../justfix-routes"; +import { NorentRoutes } from "../norent/routes"; export type JustfixAmplitudeUserProperties = { city: string; @@ -117,11 +119,6 @@ export function trackLogoutInAmplitude(s: AllSessionInfo) { getAmplitude()?.setUserProperties(getUserPropertiesFromSession(s)); } -const FRIENDLY_SITE_NAMES: { [k in SiteChoice]: string } = { - JUSTFIX: "justfix.nyc", - NORENT: "norent.org", -}; - function getPageInfo(pathname: string): PageInfo { const serverInfo = getGlobalAppServerInfo(); return { @@ -141,18 +138,9 @@ function unlocalizePathname( : pathname; } -function getFriendlyAmplitudePagePath( - pathname: string, - serverInfo = getGlobalAppServerInfo() -): string { - const siteName = FRIENDLY_SITE_NAMES[serverInfo.siteType]; - pathname = unlocalizePathname(pathname, serverInfo); - return `${siteName}${pathname}`; -} - export function logAmplitudePageView(pathname: string) { const data: PageInfo = getPageInfo(pathname); - const eventName = `Viewed page ${getFriendlyAmplitudePagePath(pathname)}`; + const eventName = `Viewed ${getAmplitudePageType(pathname)}`; getAmplitude()?.logEvent(eventName, data); } @@ -179,9 +167,6 @@ export function logAmplitudeFormSubmission(options: { } } - const formName = options.formId ? `Form ${options.formId}` : "Form"; - const friendlyPath = getFriendlyAmplitudePagePath(options.pathname); - const eventName = `Submitted ${formName} on ${friendlyPath}`; const data: FormSubmissionEventData = { ...getPageInfo(options.pathname), formKind: options.formKind, @@ -190,5 +175,51 @@ export function logAmplitudeFormSubmission(options: { errorMessages, errorCodes, }; + const eventName = + errorMessages && errorMessages.length + ? "Submitted form with errors" + : "Submitted form successfully"; getAmplitude()?.logEvent(eventName, data); } + +type StringMapping = { + [k: string]: string; +}; + +function findBestPage(pathname: string, mapping: StringMapping): string { + for (let [prefix, name] of Object.entries(mapping)) { + if (pathname.startsWith(prefix)) { + return `${name} page`; + } + } + return "page"; +} + +function getJustfixPageType(pathname: string): string { + const r = JustfixRoutes.locale; + if (pathname === r.home) return "DDO"; + return findBestPage(pathname, { + [r.ehp.prefix]: "Emergency HP Action", + [r.hp.prefix]: "HP Action", + [r.loc.prefix]: "Letter of Complaint", + [r.rh.prefix]: "Rent History", + }); +} + +function getNorentPageType(pathname: string): string { + const r = NorentRoutes.locale; + return findBestPage(pathname, { + [r.letter.prefix]: "letter builder", + }); +} + +export function getAmplitudePageType(pathname: string): string { + const { siteType } = getGlobalAppServerInfo(); + + switch (siteType) { + case "JUSTFIX": + return "JustFix " + getJustfixPageType(pathname); + case "NORENT": + return "NoRent " + getNorentPageType(pathname); + } +}