Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Amplitude integration to front-end. #1439

Merged
merged 4 commits into from
May 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions frontend/lib/analytics/amplitude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
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 } 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";
import JustfixRoutes from "../justfix-routes";
import { NorentRoutes } from "../norent/routes";

export type JustfixAmplitudeUserProperties = {
city: string;
state: USStateChoice;
signupIntent: SignupIntent;
leaseType: LeaseChoice;
prefersLegacyApp: boolean | null;
isEmailVerified: boolean;
hasSentNorentLetter: boolean;
hasFiledEHPA: boolean;
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,
"setUserProperties"
> & {
setUserProperties(properties: Partial<JustfixAmplitudeUserProperties>): void;
};

export interface JustfixAmplitudeAPI {
getInstance(): JustfixAmplitudeClient;
}

declare global {
interface Window {
amplitude: JustfixAmplitudeAPI | undefined;
}
}

function getAmplitude(): JustfixAmplitudeClient | undefined {
if (typeof window === "undefined") return undefined;
return window.amplitude?.getInstance();
}

function getUserPropertiesFromSession(
s: AllSessionInfo
): Partial<JustfixAmplitudeUserProperties> {
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) {
// 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));
}

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));
}

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;
}

export function logAmplitudePageView(pathname: string) {
const data: PageInfo = getPageInfo(pathname);
const eventName = `Viewed ${getAmplitudePageType(pathname)}`;
getAmplitude()?.logEvent(eventName, data);
}

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}`);
}
}
}
}

const data: FormSubmissionEventData = {
...getPageInfo(options.pathname),
formKind: options.formKind,
formId: options.formId,
redirect: options.redirect ?? undefined,
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);
}
}
16 changes: 16 additions & 0 deletions frontend/lib/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ import { getNorentJumpToTopOfPageRoutes } from "./norent/routes";
import { SupportedLocale } from "./i18n";
import { getGlobalSiteRoutes } from "./routes";
import { ensureNextRedirectIsHard } from "./browser-redirect";
import {
updateAmplitudeUserPropertiesOnSessionChange,
trackLoginInAmplitude,
trackLogoutInAmplitude,
logAmplitudePageView,
} 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
Expand Down Expand Up @@ -229,6 +235,7 @@ export class AppWithoutRouter extends React.Component<
) {
if (prevPathname !== pathname) {
trackPageView(pathname);
logAmplitudePageView(pathname);
this.handleFocusDuringPathnameChange(prevPathname, pathname, hash);
this.handleScrollPositionDuringPathnameChange(
prevPathname,
Expand Down Expand Up @@ -259,9 +266,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
Expand All @@ -272,6 +282,7 @@ export class AppWithoutRouter extends React.Component<
if (this.state.session.userId !== null) {
this.handleLogin();
}
logAmplitudePageView(this.props.location.pathname);
}

componentDidUpdate(prevProps: AppPropsWithRouter, prevState: AppState) {
Expand All @@ -281,6 +292,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;
Expand Down
15 changes: 15 additions & 0 deletions frontend/lib/forms/form-submitter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { logAmplitudeFormSubmission } from "../analytics/amplitude";

export type FormSubmitterProps<
FormInput,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions frontend/lib/justfix-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ export type LocalizedRouteInfo = ReturnType<typeof createLocalizedRouteInfo>;

function createLocalizedRouteInfo(prefix: string) {
return {
/** The locale prefix, e.g. `/en`. */
[ROUTE_PREFIX]: prefix,

/** The login page. */
login: `${prefix}/login`,

Expand Down
5 changes: 4 additions & 1 deletion frontend/lib/norent/routes.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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}/`,

Expand Down
5 changes: 4 additions & 1 deletion frontend/lib/routes.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions project/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading