Add Google Analytics to registrar console

To support the open source community, which may want to use different analytics services, we implement a soy template for analytics services that:

1) Does not require users to implement Google Analytics
2) Allows users to add their own analytics code to `Analytics.soy`
3) Gives users the flexibility to pass as much or as little static configuration to their custom analytics code as needed.
4) Ensures that users can merge upstream Nomulus code in the future without having to delete their custom analytics implementations
5) Does not allow code to be injected from configuration, which Soy as a framework actively discourages.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=248340081
This commit is contained in:
jianglai 2019-05-15 08:44:56 -07:00
parent 7f69ebc5d9
commit b12a462f5e
15 changed files with 135 additions and 3 deletions

View file

@ -35,6 +35,9 @@ import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.net.URI;
import java.net.URL;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
@ -198,6 +201,22 @@ public final class RegistryConfig {
return config.registrarConsole.technicalDocsUrl;
}
/**
* Configuration for analytics services installed in the web console.
*
* @see google.registry.ui.server.registrar.ConsoleUiAction
* @see google.registry.ui.soy.AnalyticsSoyInfo
*/
@Provides
@Config("analyticsConfig")
public static Map<String, Object> provideAnalyticsConfig(RegistryConfigSettings config) {
// Can't be an ImmutableMap because it may contain null values.
HashMap<String, Object> analyticsConfig = new HashMap<>();
analyticsConfig.put(
"googleAnalyticsId", config.registrarConsole.analyticsConfig.googleAnalyticsId);
return Collections.unmodifiableMap(analyticsConfig);
}
/**
* Returns the Google Cloud Storage bucket for storing zone files.
*

View file

@ -154,6 +154,12 @@ public class RegistryConfigSettings {
public String announcementsEmailAddress;
public String integrationEmailAddress;
public String technicalDocsUrl;
public AnalyticsConfig analyticsConfig;
}
/** Configuration for analytics services installed in the registrar console */
public static class AnalyticsConfig {
public String googleAnalyticsId;
}
/** Configuration for monitoring. */

View file

@ -332,6 +332,11 @@ registrarConsole:
# URL linking to directory of technical support docs on the registry.
technicalDocsUrl: http://example.com/your_support_docs/
# Configuration for all analytics services installed in the web console
analyticsConfig:
# Google Analytics account where data on console use is sent, optional
googleAnalyticsId: null
monitoring:
# Max queries per second for the Google Cloud Monitoring V3 (aka Stackdriver)
# API. The limit can be adjusted by contacting Cloud Support.

View file

@ -48,6 +48,7 @@ import google.registry.ui.server.SoyTemplateUtils;
import google.registry.ui.soy.registrar.OteSetupConsoleSoyInfo;
import google.registry.util.StringGenerator;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import javax.inject.Inject;
import javax.inject.Named;
@ -79,6 +80,7 @@ public final class ConsoleOteSetupAction implements Runnable {
SoyTemplateUtils.createTofuSupplier(
google.registry.ui.soy.ConsoleSoyInfo.getInstance(),
google.registry.ui.soy.FormsSoyInfo.getInstance(),
google.registry.ui.soy.AnalyticsSoyInfo.getInstance(),
google.registry.ui.soy.registrar.OteSetupConsoleSoyInfo.getInstance());
@VisibleForTesting // webdriver and screenshot tests need this
@ -98,6 +100,7 @@ public final class ConsoleOteSetupAction implements Runnable {
@Inject SendEmailUtils sendEmailUtils;
@Inject @Config("logoFilename") String logoFilename;
@Inject @Config("productName") String productName;
@Inject @Config("analyticsConfig") Map<String, Object> analyticsConfig;
@Inject @Named("base58StringGenerator") StringGenerator passwordGenerator;
@Inject @Parameter("clientId") Optional<String> clientId;
@Inject @Parameter("email") Optional<String> email;
@ -140,6 +143,7 @@ public final class ConsoleOteSetupAction implements Runnable {
data.put("username", user.getNickname());
data.put("logoutUrl", userService.createLogoutURL(PATH));
data.put("xsrfToken", xsrfTokenManager.generateToken(user.getEmail()));
data.put("analyticsConfig", analyticsConfig);
response.setContentType(MediaType.HTML_UTF_8);
if (!registrarAccessor.isAdmin()) {

View file

@ -56,6 +56,7 @@ import google.registry.ui.server.SoyTemplateUtils;
import google.registry.ui.soy.registrar.RegistrarCreateConsoleSoyInfo;
import google.registry.util.StringGenerator;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import javax.inject.Inject;
@ -90,6 +91,7 @@ public final class ConsoleRegistrarCreatorAction implements Runnable {
SoyTemplateUtils.createTofuSupplier(
google.registry.ui.soy.ConsoleSoyInfo.getInstance(),
google.registry.ui.soy.FormsSoyInfo.getInstance(),
google.registry.ui.soy.AnalyticsSoyInfo.getInstance(),
google.registry.ui.soy.registrar.RegistrarCreateConsoleSoyInfo.getInstance());
@VisibleForTesting // webdriver and screenshot tests need this
@ -109,6 +111,7 @@ public final class ConsoleRegistrarCreatorAction implements Runnable {
@Inject SendEmailUtils sendEmailUtils;
@Inject @Config("logoFilename") String logoFilename;
@Inject @Config("productName") String productName;
@Inject @Config("analyticsConfig") Map<String, Object> analyticsConfig;
@Inject @Named("base58StringGenerator") StringGenerator passwordGenerator;
@Inject @Named("digitOnlyStringGenerator") StringGenerator passcodeGenerator;
@Inject @Parameter("clientId") Optional<String> clientId;
@ -167,6 +170,7 @@ public final class ConsoleRegistrarCreatorAction implements Runnable {
data.put("username", user.getNickname());
data.put("logoutUrl", userService.createLogoutURL(PATH));
data.put("xsrfToken", xsrfTokenManager.generateToken(user.getEmail()));
data.put("analyticsConfig", analyticsConfig);
response.setContentType(MediaType.HTML_UTF_8);
if (!registrarAccessor.isAdmin()) {

View file

@ -47,6 +47,7 @@ import google.registry.request.auth.AuthenticatedRegistrarAccessor.Role;
import google.registry.security.XsrfTokenManager;
import google.registry.ui.server.SoyTemplateUtils;
import google.registry.ui.soy.registrar.ConsoleSoyInfo;
import java.util.Map;
import java.util.Optional;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
@ -62,7 +63,8 @@ public final class ConsoleUiAction implements Runnable {
private static final Supplier<SoyTofu> TOFU_SUPPLIER =
SoyTemplateUtils.createTofuSupplier(
google.registry.ui.soy.ConsoleSoyInfo.getInstance(),
google.registry.ui.soy.registrar.ConsoleSoyInfo.getInstance());
google.registry.ui.soy.registrar.ConsoleSoyInfo.getInstance(),
google.registry.ui.soy.AnalyticsSoyInfo.getInstance());
@VisibleForTesting // webdriver and screenshot tests need this
public static final Supplier<SoyCssRenamingMap> CSS_RENAMING_MAP_SUPPLIER =
@ -86,6 +88,7 @@ public final class ConsoleUiAction implements Runnable {
@Inject @Config("supportPhoneNumber") String supportPhoneNumber;
@Inject @Config("technicalDocsUrl") String technicalDocsUrl;
@Inject @Config("registrarConsoleEnabled") boolean enabled;
@Inject @Config("analyticsConfig") Map<String, Object> analyticsConfig;
@Inject @Parameter(PARAM_CLIENT_ID) Optional<String> paramClientId;
@Inject ConsoleUiAction() {}
@ -120,6 +123,7 @@ public final class ConsoleUiAction implements Runnable {
data.put("announcementsEmail", announcementsEmail);
data.put("supportPhoneNumber", supportPhoneNumber);
data.put("technicalDocsUrl", technicalDocsUrl);
data.put("analyticsConfig", analyticsConfig);
if (!enabled) {
response.setStatus(SC_SERVICE_UNAVAILABLE);
response.setPayload(
@ -136,7 +140,7 @@ public final class ConsoleUiAction implements Runnable {
ImmutableSetMultimap<String, Role> roleMap = registrarAccessor.getAllClientIdWithRoles();
data.put("allClientIds", roleMap.keySet());
data.put("environment", environment.toString());
// We set the initual value to the value that will show if guessClientId throws.
// We set the initial value to the value that will show if guessClientId throws.
String clientId = "<null>";
try {
clientId = paramClientId.orElse(registrarAccessor.guessClientId());

View file

@ -0,0 +1,34 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
{namespace registry.soy.analytics}
/**
* The JS template required to install Google Analytics for the registrar console.
*/
{template .googleAnalytics}
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{let $id: $analyticsConfig.googleAnalyticsId /}
{if $id}
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id={$id}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){lb}dataLayer.push(arguments);{rb}
gtag('js', new Date());
gtag('config', {$id});
</script>
{/if}
{/template}

View file

@ -21,6 +21,7 @@
{template .header}
{@param app: string} /** App identifier, e.g. 'admin', 'registrar'. */
{@param? subtitle: string} /** Extra stuff to put in {@code <title>}. */
{@param analyticsConfig: [googleAnalyticsId: string|null]}
<!doctype html>
<meta charset="utf-8">
{literal}<!--
@ -56,6 +57,7 @@
<!-- No DEBUG option set. -->
{/switch}
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Open+Sans:300">
{call registry.soy.analytics.googleAnalytics data="all" /}
{/template}

View file

@ -35,10 +35,12 @@
{@param supportPhoneNumber: string}
{@param technicalDocsUrl: string}
{@param environment: string}
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{call registry.soy.console.header}
{param app: 'registrar' /}
{param subtitle: 'Registrar Console' /}
{param analyticsConfig: $analyticsConfig /}
{/call}
{call registry.soy.console.googlebar data="all" /}
<div id="reg-app">
@ -145,9 +147,11 @@
{template .disabled}
{@param logoFilename: string}
{@param productName: string}
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{call registry.soy.console.header}
{param app: 'registrar' /}
{param subtitle: 'Console Disabled' /}
{param analyticsConfig: $analyticsConfig /}
{/call}
<div class="{css('whoAreYou-disabled')}">
<a class="{css('logo')}" href="/registrar">
@ -172,9 +176,11 @@
{@param logoFilename: string}
{@param productName: string}
{@param? clientId: string}
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{call registry.soy.console.header}
{param app: 'registrar' /}
{param subtitle: 'Not Authorized' /}
{param analyticsConfig: $analyticsConfig /}
{/call}
<div class="{css('whoAreYou')}">
<a class="{css('logo')}" href="/registrar">

View file

@ -23,6 +23,8 @@
{@param username: string} /** Arbitrary username to display. */
{@param logoutUrl: string} /** Generated URL for logging out of Google. */
{@param productName: string} /** Name to display for this software product. */
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{@param logoFilename: string}
{@param? errorMessage: string} /** If set - display the error message above the form. */
{@param? baseClientId: string} /** If set - an initial value to fill for the base client ID. */
@ -31,6 +33,7 @@
{call registry.soy.console.header}
{param app: 'registrar' /}
{param subtitle: 'OT&E setup' /}
{param analyticsConfig: $analyticsConfig /}
{/call}
{call registry.soy.console.googlebar data="all" /}
<div id="reg-content-and-footer">
@ -58,10 +61,12 @@
{@param logoutUrl: string} /** Generated URL for logging out of Google. */
{@param productName: string} /** Name to display for this software product. */
{@param logoFilename: string}
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{call registry.soy.console.header}
{param app: 'registrar' /}
{param subtitle: 'OT&E setup' /}
{param analyticsConfig: $analyticsConfig /}
{/call}
{call registry.soy.console.googlebar data="all" /}
<div id="reg-content-and-footer">
@ -100,6 +105,7 @@
{@param xsrfToken: string} /** Security token. */
{@param? baseClientId: string} /** If set - an initial value to fill for the base client ID. */
{@param? contactEmail: string} /** If set - an initial value to fill for the email. */
<form name="item" class="{css('item')}" method="post" action="/registrar-ote-setup">
<table>
<tr class="{css('kd-settings-pane-section')}">
@ -162,9 +168,12 @@
{@param logoutUrl: string} /** Generated URL for logging out of Google. */
{@param logoFilename: string}
{@param productName: string}
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{call registry.soy.console.header}
{param app: 'registrar' /}
{param subtitle: 'Not Authorized' /}
{param analyticsConfig: $analyticsConfig /}
{/call}
<div class="{css('whoAreYou')}">
<a class="{css('logo')}" href="/registrar">

View file

@ -24,6 +24,7 @@
{@param logoutUrl: string} /** Generated URL for logging out of Google. */
{@param productName: string} /** Name to display for this software product. */
{@param logoFilename: string}
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{@param? errorMessage: string} /** If set - display the error message above the form. */
{@param? clientId: string} /** If set - an initial value for the client ID. */
{@param? name: string} /** If set - an initial value for the Registrar name. */
@ -43,6 +44,7 @@
{call registry.soy.console.header}
{param app: 'registrar' /}
{param subtitle: 'Create Registrar' /}
{param analyticsConfig: $analyticsConfig /}
{/call}
{call registry.soy.console.googlebar data="all" /}
<div id="reg-content-and-footer">
@ -70,10 +72,12 @@
{@param logoutUrl: string} /** Generated URL for logging out of Google. */
{@param productName: string} /** Name to display for this software product. */
{@param logoFilename: string}
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{call registry.soy.console.header}
{param app: 'registrar' /}
{param subtitle: 'Create Registrar' /}
{param analyticsConfig: $analyticsConfig /}
{/call}
{call registry.soy.console.googlebar data="all" /}
<div id="reg-content-and-footer">
@ -335,9 +339,12 @@
{@param logoutUrl: string} /** Generated URL for logging out of Google. */
{@param logoFilename: string}
{@param productName: string}
{@param analyticsConfig: [googleAnalyticsId: string|null]}
{call registry.soy.console.header}
{param app: 'registrar' /}
{param subtitle: 'Not Authorized' /}
{param analyticsConfig: $analyticsConfig /}
{/call}
<div class="{css('whoAreYou')}">
<a class="{css('logo')}" href="/registrar">

View file

@ -61,6 +61,7 @@ registry.registrar.ConsoleTestUtil.renderConsoleMain = function(
supportPhoneNumber: args.supportPhoneNumber || '+1 (888) 555 0123',
technicalDocsUrl: args.technicalDocsUrl || 'http://example.com/techdocs',
environment: args.environment || 'UNITTEST',
analyticsConfig: args.analyticsConfig || {googleAnalyticsId: null},
});
};
@ -80,6 +81,9 @@ registry.registrar.ConsoleTestUtil.visit = function(
opt_args.clientId = opt_args.clientId || 'dummyRegistrarId';
opt_args.xsrfToken = opt_args.xsrfToken || 'dummyXsrfToken';
opt_args.isAdmin = !!opt_args.isAdmin;
opt_args.analyticsConfig =
opt_args.analyticsConfig || {googleAnalyticsIds: null};
// set the default isOwner to be the opposite of isAdmin.
// That way, if we don't explicitly state them both we get what we'd expect:
// {} -> OWNER (the "regular" case of a visitor to the console)

View file

@ -27,6 +27,7 @@ import static org.mockito.Mockito.when;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserServiceFactory;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSetMultimap;
import google.registry.config.RegistryEnvironment;
import google.registry.model.registry.Registry;
@ -97,6 +98,7 @@ public final class ConsoleOteSetupActionTest {
action.productName = "Nomulus";
action.clientId = Optional.empty();
action.email = Optional.empty();
action.analyticsConfig = ImmutableMap.of("googleAnalyticsId", "sampleId");
action.optionalPassword = Optional.empty();
action.passwordGenerator = new DeterministicStringGenerator("abcdefghijklmnopqrstuvwxyz");
@ -115,6 +117,7 @@ public final class ConsoleOteSetupActionTest {
public void testGet_authorized() {
action.run();
assertThat(response.getPayload()).contains("<h1>Setup OT&E</h1>");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
}
@Test
@ -129,6 +132,7 @@ public final class ConsoleOteSetupActionTest {
AuthenticatedRegistrarAccessor.createForTesting(ImmutableSetMultimap.of());
action.run();
assertThat(response.getPayload()).contains("<h1>You need permission</h1>");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
}
@Test
@ -160,6 +164,7 @@ public final class ConsoleOteSetupActionTest {
+ " Registrar myclientid-4 with access to TLD myclientid-ga\n"
+ " Registrar myclientid-5 with access to TLD myclientid-eap\n"
+ "Gave user contact@registry.example web access to these Registrars\n");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
}
@Test
@ -189,5 +194,6 @@ public final class ConsoleOteSetupActionTest {
action.method = Method.POST;
action.run();
assertThat(response.getPayload()).contains("<h1>You need permission</h1>");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
}
}

View file

@ -26,6 +26,7 @@ import static org.mockito.Mockito.when;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserServiceFactory;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSetMultimap;
import google.registry.config.RegistryEnvironment;
import google.registry.model.registrar.Registrar;
@ -119,6 +120,8 @@ public final class ConsoleRegistrarCreatorActionTest {
action.passwordGenerator = new DeterministicStringGenerator("abcdefghijklmnopqrstuvwxyz");
action.passcodeGenerator = new DeterministicStringGenerator("314159265");
action.analyticsConfig = ImmutableMap.of("googleAnalyticsId", "sampleId");
}
@Test
@ -134,6 +137,7 @@ public final class ConsoleRegistrarCreatorActionTest {
public void testGet_authorized() {
action.run();
assertThat(response.getPayload()).contains("<h1>Create Registrar</h1>");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
}
@Test
@ -141,6 +145,7 @@ public final class ConsoleRegistrarCreatorActionTest {
action.registryEnvironment = RegistryEnvironment.PRODUCTION;
action.run();
assertThat(response.getPayload()).contains("<h1>Create Registrar</h1>");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
}
@Test
@ -149,6 +154,7 @@ public final class ConsoleRegistrarCreatorActionTest {
AuthenticatedRegistrarAccessor.createForTesting(ImmutableSetMultimap.of());
action.run();
assertThat(response.getPayload()).contains("<h1>You need permission</h1>");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
}
@Test
@ -243,6 +249,7 @@ public final class ConsoleRegistrarCreatorActionTest {
assertThat(response.getPayload())
.contains("<h1>Successfully created Registrar myclientid</h1>");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
Registrar registrar = loadByClientId("myclientid").orElse(null);
assertThat(registrar).isNotNull();
@ -414,5 +421,6 @@ public final class ConsoleRegistrarCreatorActionTest {
action.method = Method.POST;
action.run();
assertThat(response.getPayload()).contains("<h1>You need permission</h1>");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
}
}

View file

@ -25,6 +25,7 @@ import static org.mockito.Mockito.when;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserServiceFactory;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.net.MediaType;
import google.registry.config.RegistryEnvironment;
@ -79,6 +80,7 @@ public class ConsoleUiActionTest {
action.paramClientId = Optional.empty();
action.authResult = AuthResult.create(AuthLevel.USER, UserAuthInfo.create(user, false));
action.environment = RegistryEnvironment.UNITTEST;
action.analyticsConfig = ImmutableMap.of("googleAnalyticsId", "sampleId");
action.registrarAccessor =
AuthenticatedRegistrarAccessor.createForTesting(
@ -122,6 +124,13 @@ public class ConsoleUiActionTest {
assertMetric("TheRegistrar", "false", "[OWNER]", "SUCCESS");
}
@Test
public void testWebPage_containsGoogleAnalyticsId() {
action.run();
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
assertMetric("TheRegistrar", "false", "[OWNER]", "SUCCESS");
}
@Test
public void testUserHasAccessAsTheRegistrar_showsRegistrarConsole() {
action.run();
@ -135,6 +144,7 @@ public class ConsoleUiActionTest {
action.enabled = false;
action.run();
assertThat(response.getPayload()).contains("<h1>Console is disabled</h1>");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
}
@Test
@ -144,6 +154,7 @@ public class ConsoleUiActionTest {
action.run();
assertThat(response.getPayload()).contains("<h1>You need permission</h1>");
assertThat(response.getPayload()).contains("not associated with Nomulus.");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
assertMetric("<null>", "false", "[]", "FORBIDDEN");
}
@ -172,6 +183,7 @@ public class ConsoleUiActionTest {
action.run();
assertThat(response.getPayload()).contains("<h1>You need permission</h1>");
assertThat(response.getPayload()).contains("not associated with the registrar fakeRegistrar.");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
assertMetric("fakeRegistrar", "true", "[]", "FORBIDDEN");
}
@ -181,6 +193,7 @@ public class ConsoleUiActionTest {
action.run();
assertThat(response.getPayload()).contains("Registrar Console");
assertThat(response.getPayload()).contains("reg-content-and-footer");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
assertMetric("NewRegistrar", "true", "[OWNER, ADMIN]", "SUCCESS");
}
@ -190,6 +203,7 @@ public class ConsoleUiActionTest {
assertThat(response.getPayload()).contains("<option value=\"TheRegistrar\" selected>");
assertThat(response.getPayload()).contains("<option value=\"NewRegistrar\">");
assertThat(response.getPayload()).contains("<option value=\"AdminRegistrar\">");
assertThat(response.getPayload()).contains("gtag('config', 'sampleId')");
assertMetric("TheRegistrar", "false", "[OWNER]", "SUCCESS");
}
}