(core) Record new user sign-ups

Summary:
Adds Google Tag Manager snippet to all login pages, and a new user
preference, recordSignUpEvent, that's set to true on first sign-in. The
client now checks for this preference, and if true, dynamically loads
Google Tag Manager to record a sign-up event. Afterwards, it removes
the preference.

Test Plan: Tested manually.

Reviewers: dsagal

Reviewed By: dsagal

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D3319
This commit is contained in:
George Gevoian 2022-03-11 12:35:29 -08:00
parent eff78ae2e1
commit ad1b4f3cff
7 changed files with 84 additions and 31 deletions

View File

@ -1,4 +1,5 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {error} from 'app/client/lib/log';
import {reportError, setErrorNotifier} from 'app/client/models/errors'; import {reportError, setErrorNotifier} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {Notifier} from 'app/client/models/NotifyModel'; import {Notifier} from 'app/client/models/NotifyModel';
@ -8,8 +9,10 @@ import {GristLoadConfig} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import {LocalPlugin} from 'app/common/plugin'; import {LocalPlugin} from 'app/common/plugin';
import {UserPrefs} from 'app/common/Prefs'; import {UserPrefs} from 'app/common/Prefs';
import {getTagManagerScript} from 'app/common/tagManager';
import {getGristConfig} from 'app/common/urlUtils';
import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {getUserPrefsObs} from 'app/client/models/UserPrefs'; import {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
export {reportError} from 'app/client/models/errors'; export {reportError} from 'app/client/models/errors';
@ -195,6 +198,35 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly orgError?: OrgError, public readonly orgError?: OrgError,
) { ) {
super(); super();
this._recordSignUpIfIsNewUser();
}
/**
* If the current user is a new user, record a sign-up event via Google Tag Manager.
*/
private _recordSignUpIfIsNewUser() {
const isNewUser = this.userPrefsObs.get().recordSignUpEvent;
if (!isNewUser) { return; }
// If Google Tag Manager isn't configured, don't record anything.
const {tagManagerId} = getGristConfig();
if (!tagManagerId) { return; }
let dataLayer = (window as any).dataLayer;
if (!dataLayer) {
// Load the Google Tag Manager script into the document.
const script = document.createElement('script');
script.innerHTML = getTagManagerScript(tagManagerId);
document.head.appendChild(script);
dataLayer = (window as any).dataLayer;
if (!dataLayer) {
error(`_recordSignUpIfIsNewUser() failed to load Google Tag Manager`);
}
}
// Send the sign-up event, and remove the recordSignUpEvent flag from preferences.
dataLayer.push({event: 'new-sign-up'});
getUserPrefObs(this.userPrefsObs, 'recordSignUpEvent').set(undefined);
} }
} }

View File

@ -170,8 +170,11 @@ export class UrlStateImpl {
const welcomeReload = Boolean(prevState.welcome) !== Boolean(newState.welcome); const welcomeReload = Boolean(prevState.welcome) !== Boolean(newState.welcome);
// Reload when link keys change, which changes what the user can access // Reload when link keys change, which changes what the user can access
const linkKeysReload = !isEqual(prevState.params?.linkParameters, newState.params?.linkParameters); const linkKeysReload = !isEqual(prevState.params?.linkParameters, newState.params?.linkParameters);
// Reload when moving to/from the Grist sign-up page.
const signupReload = [prevState.login, newState.login].includes('signup')
&& prevState.login !== newState.login;
return Boolean(orgReload || accountReload || billingReload || gristConfig.errPage return Boolean(orgReload || accountReload || billingReload || gristConfig.errPage
|| docReload || welcomeReload || linkKeysReload); || docReload || welcomeReload || linkKeysReload || signupReload);
} }
/** /**

View File

@ -17,6 +17,9 @@ export interface UserPrefs extends Prefs {
// Whether to ask the user to fill out a form about their use-case, on opening the DocMenu page. // Whether to ask the user to fill out a form about their use-case, on opening the DocMenu page.
// Set to true on first login, then reset when the form is closed, so that it only shows once. // Set to true on first login, then reset when the form is closed, so that it only shows once.
showNewUserQuestions?: boolean; showNewUserQuestions?: boolean;
// Whether to record a new sign-up event via Google Tag Manager. Set to true on first login, then
// reset on first page load (after the event is sent), so that it's only recorded once.
recordSignUpEvent?: boolean;
} }
export interface UserOrgPrefs extends Prefs { export interface UserOrgPrefs extends Prefs {

View File

@ -496,6 +496,9 @@ export interface GristLoadConfig {
// Whether there is somewhere for survey data to go. // Whether there is somewhere for survey data to go.
survey?: boolean; survey?: boolean;
// Google Tag Manager id. Currently only used to load tag manager for reporting new sign-ups.
tagManagerId?: string;
} }
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of // Acceptable org subdomains are alphanumeric (hyphen also allowed) and of

29
app/common/tagManager.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* Returns the Google Tag Manager snippet to insert into <head> of the page, if
* `tagId` is set to a non-empty value. Otherwise returns an empty string.
*/
export function getTagManagerSnippet(tagId?: string) {
// Note also that we only insert the snippet for the <head>. The second recommended part (for
// <body>) is for <noscript> scenario, which doesn't apply to the Grist app (such visits, if
// any, wouldn't work and shouldn't be counted for any metrics we care about).
if (!tagId) { return ""; }
return `
<!-- Google Tag Manager -->
<script>${getTagManagerScript(tagId)}</script>
<!-- End Google Tag Manager -->
`;
}
/**
* Returns the body of the Google Tag Manager script. This is suitable for use by the client,
* since it must dynamically load it by calling `document.createElement('script')` and setting
* its `innerHTML`.
*/
export function getTagManagerScript(tagId: string) {
return `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${tagId}');`;
}

View File

@ -722,11 +722,14 @@ export class FlexServer implements GristServer {
// Reset isFirstTimeUser flag. // Reset isFirstTimeUser flag.
await this._dbManager.updateUser(user.id, {isFirstTimeUser: false}); await this._dbManager.updateUser(user.id, {isFirstTimeUser: false});
// This is a good time to set another flag (showNewUserQuestions), to show a popup with // This is a good time to set some other flags, for showing a popup with welcome question(s)
// welcome question(s) to this new user. Both flags are scoped to the user, but // to this new user and recording their sign-up with Google Tag Manager. These flags are also
// isFirstTimeUser has a dedicated DB field because it predates userPrefs. Note that the // scoped to the user, but isFirstTimeUser has a dedicated DB field because it predates userPrefs.
// updateOrg() method handles all levels of prefs (for user, user+org, or org). // Note that the updateOrg() method handles all levels of prefs (for user, user+org, or org).
await this._dbManager.updateOrg(getScope(req), 0, {userPrefs: {showNewUserQuestions: true}}); await this._dbManager.updateOrg(getScope(req), 0, {userPrefs: {
showNewUserQuestions: true,
recordSignUpEvent: true,
}});
// Redirect to teams page if users has access to more than one org. Otherwise, redirect to // Redirect to teams page if users has access to more than one org. Otherwise, redirect to
// personal org. // personal org.
@ -1138,7 +1141,7 @@ export class FlexServer implements GristServer {
// These are some special-purpose welcome pages, with no middleware. // These are some special-purpose welcome pages, with no middleware.
this.app.get(/\/welcome\/(signup|verify|teams|select-account)/, expressWrap(async (req, resp, next) => { this.app.get(/\/welcome\/(signup|verify|teams|select-account)/, expressWrap(async (req, resp, next) => {
return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}, googleTagManager: true}); return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}, googleTagManager: 'anon'});
})); }));
this.app.post('/welcome/info', ...middleware, expressWrap(async (req, resp, next) => { this.app.post('/welcome/info', ...middleware, expressWrap(async (req, resp, next) => {

View File

@ -1,4 +1,5 @@
import {GristLoadConfig} from 'app/common/gristUrls'; import {GristLoadConfig} from 'app/common/gristUrls';
import {getTagManagerSnippet} from 'app/common/tagManager';
import {isAnonymousUser} from 'app/server/lib/Authorizer'; import {isAnonymousUser} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {GristServer} from 'app/server/lib/GristServer'; import {GristServer} from 'app/server/lib/GristServer';
@ -47,6 +48,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
timestampMs: Date.now(), timestampMs: Date.now(),
enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL), enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL),
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO), survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
...extra, ...extra,
}; };
} }
@ -85,7 +87,7 @@ export function makeSendAppPage(opts: {
const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) || const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) ||
options.googleTagManager === true; options.googleTagManager === true;
const tagManagerSnippet = needTagManager ? getTagManagerSnippet() : ''; const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : '';
const staticOrigin = process.env.APP_STATIC_URL || ""; const staticOrigin = process.env.APP_STATIC_URL || "";
const staticBaseUrl = `${staticOrigin}/v/${options.tag || tag}/`; const staticBaseUrl = `${staticOrigin}/v/${options.tag || tag}/`;
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : ""; const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
@ -101,25 +103,3 @@ function shouldSupportAnon() {
// Enable UI for anonymous access if a flag is explicitly set in the environment // Enable UI for anonymous access if a flag is explicitly set in the environment
return process.env.GRIST_SUPPORT_ANON === "true"; return process.env.GRIST_SUPPORT_ANON === "true";
} }
/**
* Returns the Google Tag Manager snippet to insert into <head> of the page, if
* GOOGLE_TAG_MANAGER_ID env var is set to a non-empty value. Otherwise returns the empty string.
*/
function getTagManagerSnippet() {
// Note also that we only insert the snippet for the <head>. The second recommended part (for
// <body>) is for <noscript> scenario, which doesn't apply to the Grist app (such visits, if
// any, wouldn't work and shouldn't be counted for any metrics we care about).
const tagId = process.env.GOOGLE_TAG_MANAGER_ID;
if (!tagId) { return ""; }
return `
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${tagId}');</script>
<!-- End Google Tag Manager -->
`;
}