Initial identity-awareness implementation for GA.

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2022-01-16 18:34:40 +01:00
committed by Eric Peterson
parent 2b06ccadb3
commit b40a0ccc4d
13 changed files with 602 additions and 36 deletions
@@ -15,10 +15,17 @@
*/
import { ConfigReader } from '@backstage/config';
import { IdentityApi } from '@backstage/core-plugin-api';
import ReactGA from 'react-ga';
import { GoogleAnalytics } from './GoogleAnalytics';
describe('GoogleAnalytics', () => {
const context = {
extension: 'App',
pluginId: 'some-plugin',
routeRef: 'unknown',
releaseNum: 1337,
};
const trackingId = 'UA-000000-0';
const basicValidConfig = new ConfigReader({
app: { analytics: { ga: { trackingId, testMode: true } } },
@@ -50,12 +57,6 @@ describe('GoogleAnalytics', () => {
});
describe('integration', () => {
const context = {
extension: 'App',
pluginId: 'some-plugin',
routeRef: 'unknown',
releaseNum: 1337,
};
const advancedConfig = new ConfigReader({
app: {
analytics: {
@@ -141,20 +142,13 @@ describe('GoogleAnalytics', () => {
context,
});
// Expect a set command first.
const [setCommand, setData] = ReactGA.testModeAPI.calls[1];
expect(setCommand).toBe('set');
expect(setData).toMatchObject({
dimension1: context.pluginId,
metric1: context.releaseNum,
});
// Followed by a send command.
const [sendCommand, sendData] = ReactGA.testModeAPI.calls[2];
expect(sendCommand).toBe('send');
expect(sendData).toMatchObject({
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/a-page',
dimension1: context.pluginId,
metric1: context.releaseNum,
});
});
@@ -208,4 +202,212 @@ describe('GoogleAnalytics', () => {
});
});
});
describe('identityApi', () => {
const identityApi = {
getBackstageIdentity: jest.fn().mockResolvedValue({
userEntityRef: 'User:default/someone',
}),
} as unknown as IdentityApi;
it('does not set userId unless explicitly configured', async () => {
// Instantiate with identityApi and default configs.
const api = GoogleAnalytics.fromConfig(basicValidConfig, { identityApi });
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// A pageview should have been fired immediately.
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/',
});
// There should not have been a UserID set.
expect(ReactGA.testModeAPI.calls).toHaveLength(2);
});
it('sets hashed userId when identityApi is provided', async () => {
// Instantiate with identityApi and identity set to optional
const optionalConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'optional' },
},
},
});
const api = GoogleAnalytics.fromConfig(optionalConfig, { identityApi });
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// A pageview should have been fired immediately.
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/',
});
// User ID should have been set after the pageview.
const [setCommand, setData] = ReactGA.testModeAPI.calls[2];
expect(setCommand).toBe('set');
expect(setData).toMatchObject({
// String indicating userEntityRef went through expected hashing.
userId: '557365723a64656661756c742f736f6d656f6e65',
});
});
it('sets pre-hashed userId when PrivateUser entity ref is provided', async () => {
(identityApi.getBackstageIdentity as jest.Mock).mockResolvedValueOnce({
userEntityRef: 'PrivateUser:hashed/s0m3hash3dvalu3',
});
const optionalConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'optional' },
},
},
});
const api = GoogleAnalytics.fromConfig(optionalConfig, { identityApi });
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// User ID should have been set after the pageview.
const [setCommand, setData] = ReactGA.testModeAPI.calls[2];
expect(setCommand).toBe('set');
expect(setData).toMatchObject({
userId: 's0m3hash3dvalu3',
});
});
it('does not set userId when identityApi is provided and ga.identity is explicitly disabled', async () => {
// Instantiate with identityApi and identity explicitly disabled.
const disabledConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'disabled' },
},
},
});
const api = GoogleAnalytics.fromConfig(disabledConfig, { identityApi });
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// A pageview should have been fired immediately.
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/',
});
// There should not have been a UserID set.
expect(ReactGA.testModeAPI.calls).toHaveLength(2);
});
it('throws error when ga.identity is required but no identityApi is provided', async () => {
// Instantiate without identityApi and identity explicitly disabled.
const requiredConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'required' },
},
},
});
expect(() => GoogleAnalytics.fromConfig(requiredConfig)).toThrow();
});
it('defers event capture when ga.identity is required', async () => {
// Instantiate with identityApi and identity explicitly required.
const requiredConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'required' },
},
},
});
const api = GoogleAnalytics.fromConfig(requiredConfig, { identityApi });
// Fire a pageview and an event.
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
api.captureEvent({
action: 'test',
subject: 'some label',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// User ID should have been set first.
const [setCommand, setData] = ReactGA.testModeAPI.calls[1];
expect(setCommand).toBe('set');
expect(setData).toMatchObject({
// String indicating userEntityRef went through expected hashing.
userId: '557365723a64656661756c742f736f6d656f6e65',
});
// Then a pageview should have been fired with a queue time.
const [pageCommand, pageData] = ReactGA.testModeAPI.calls[2];
expect(pageCommand).toBe('send');
expect(pageData).toMatchObject({
hitType: 'pageview',
page: '/',
queueTime: expect.any(Number),
});
// Then an event should have been fired with a queue time.
const [eventCommand, eventData] = ReactGA.testModeAPI.calls[3];
expect(eventCommand).toBe('send');
expect(eventData).toMatchObject({
hitType: 'event',
queueTime: expect.any(Number),
});
// And subsequent hits should not have a queue time.
api.captureEvent({
action: 'navigate',
subject: '/page-2',
context,
});
const [lastCommand, lastData] = ReactGA.testModeAPI.calls[4];
expect(lastCommand).toBe('send');
expect(lastData).toMatchObject({
hitType: 'pageview',
page: '/page-2',
});
expect(lastData.queueTime).toBeUndefined();
});
});
});
@@ -15,13 +15,16 @@
*/
import ReactGA from 'react-ga';
import { parseEntityRef } from '@backstage/catalog-model';
import {
AnalyticsApi,
AnalyticsContextValue,
AnalyticsEventAttributes,
AnalyticsEvent,
IdentityApi,
} from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
import { DeferredCapture } from '../../../util';
type CustomDimensionOrMetricConfig = {
type: 'dimension' | 'metric';
@@ -32,21 +35,33 @@ type CustomDimensionOrMetricConfig = {
/**
* Google Analytics API provider for the Backstage Analytics API.
* @public
*/
export class GoogleAnalytics implements AnalyticsApi {
private readonly cdmConfig: CustomDimensionOrMetricConfig[];
private readonly capture: DeferredCapture;
/**
* Instantiate the implementation and initialize ReactGA.
*/
private constructor(options: {
identityApi?: IdentityApi;
cdmConfig: CustomDimensionOrMetricConfig[];
identity: string;
trackingId: string;
scriptSrc?: string;
testMode: boolean;
debug: boolean;
}) {
const { cdmConfig, trackingId, scriptSrc, testMode, debug } = options;
const {
cdmConfig,
identity,
trackingId,
identityApi,
scriptSrc,
testMode,
debug,
} = options;
this.cdmConfig = cdmConfig;
@@ -57,15 +72,28 @@ export class GoogleAnalytics implements AnalyticsApi {
gaAddress: scriptSrc,
titleCase: false,
});
// If identity is required, defer event capture until identity is known.
this.capture = new DeferredCapture({ defer: identity === 'required' });
// Capture user only when explicitly enabled and provided.
if (identity !== 'disabled' && identityApi) {
this.setUserFrom(identityApi);
}
}
/**
* Instantiate a fully configured GA Analytics API implementation.
*/
static fromConfig(config: Config) {
static fromConfig(
config: Config,
options: { identityApi?: IdentityApi } = {},
) {
// Get all necessary configuration.
const trackingId = config.getString('app.analytics.ga.trackingId');
const scriptSrc = config.getOptionalString('app.analytics.ga.scriptSrc');
const identity =
config.getOptionalString('app.analytics.ga.identity') || 'disabled';
const debug = config.getOptionalBoolean('app.analytics.ga.debug') ?? false;
const testMode =
config.getOptionalBoolean('app.analytics.ga.testMode') ?? false;
@@ -83,8 +111,16 @@ export class GoogleAnalytics implements AnalyticsApi {
};
}) ?? [];
if (identity === 'required' && !options.identityApi) {
throw new Error(
'Invalid config: identity API must be provided to deps when ga.identity is required',
);
}
// Return an implementation instance.
return new GoogleAnalytics({
...options,
identity,
trackingId,
scriptSrc,
cdmConfig,
@@ -103,16 +139,11 @@ export class GoogleAnalytics implements AnalyticsApi {
const customMetadata = this.getCustomDimensionMetrics(context, attributes);
if (action === 'navigate' && context.extension === 'App') {
// Set any/all custom dimensions.
if (Object.keys(customMetadata).length) {
ReactGA.set(customMetadata);
}
ReactGA.pageview(subject);
this.capture.pageview(subject, customMetadata);
return;
}
ReactGA.event({
this.capture.event({
category: context.extension || 'App',
action,
label: subject,
@@ -151,4 +182,63 @@ export class GoogleAnalytics implements AnalyticsApi {
return customDimensionsMetrics;
}
/**
* Sets the GA userId, based on the `userEntityRef` set on the backstage
* identity loaded from a given Backstage Identity API instance. Because
* Google forbids sending any PII (including on the userId field), we hash
* the entire `userEntityRef` on behalf of integrators:
*
* - With value `User:default/name`, userId becomes `sha256(User:default/name)`
*
* If an integrator wishes to use an alternative hashing mechanism or an
* entirely different value, they may do so by passing a dummy Identity API
* implementation which returns a `userEntityRef` whose kind is the literal
* string `PrivateUser`, whose namespace is anything (it will be ignored) and
* whose name is the pre-hashed ID value.
*
* - With value `PrivateUser:default/a0n3b4n3`, userId becomes `a0n3b4n3`
* - With `PrivateUser:xyz/a0n3b4n3`, userId is `a0n3b4n3`
*
* Note: this feature requires that an integrator has set up a Google
* Analytics User ID view in the property used to track Backstage.
*/
private async setUserFrom(identityApi: IdentityApi) {
const { userEntityRef } = await identityApi.getBackstageIdentity();
// Prevent PII from being passed to Google Analytics.
const userId = await this.getPrivateUserId(userEntityRef);
// Set the user ID.
ReactGA.set({ userId });
// Notify the deferred capture mechanism that it may proceed.
this.capture.setReady();
}
/**
* Returns a PII-free user ID for use in Google Analytics.
*/
private getPrivateUserId(userEntityRef: string): Promise<string> {
const entity = parseEntityRef(userEntityRef);
// Mechanism allowing integrators to provide their own hashed values.
if (entity.kind === 'PrivateUser') {
return Promise.resolve(entity.name);
}
return this.hash(userEntityRef);
}
/**
* Simple hash function; relies on web cryptography + the sha-256 algorithm.
*/
private async hash(value: string): Promise<string> {
const digest = await crypto.subtle.digest(
'sha-256',
new TextEncoder().encode(value),
);
const hashArray = Array.from(new Uint8Array(digest));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
}
+1 -1
View File
@@ -15,4 +15,4 @@
*/
export { analyticsModuleGA } from './plugin';
export { GoogleAnalytics } from './apis/implementations/AnalyticsApi';
export * from './apis/implementations/AnalyticsApi';
@@ -15,6 +15,9 @@
*/
import { createPlugin } from '@backstage/core-plugin-api';
/**
* @public
*/
export const analyticsModuleGA = createPlugin({
id: 'analytics-provider-ga',
});
@@ -15,3 +15,20 @@
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
// eslint-disable-next-line no-restricted-imports
import { TextEncoder } from 'util';
// Mock browser crypto.subtle.digest method for sha-256 hashing.
Object.defineProperty(global.self, 'crypto', {
value: {
subtle: {
digest: (_algo: string, data: Uint8Array): ArrayBuffer => data.buffer,
},
},
});
// Also used in browser-based APIs for hashing.
Object.defineProperty(global.self, 'TextEncoder', {
value: TextEncoder,
});
@@ -0,0 +1,140 @@
/*
* Copyright 2022 The Backstage Authors
*
* 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.
*/
import ReactGA from 'react-ga';
type Hit = {
timestamp: number;
data: {
hitType: 'pageview' | 'event';
[x: string]: any;
};
};
/**
* A wrapper around ReactGA that can optionally handle latent capture logic.
*
* - When defer is `false`, event data is sent directly to GA.
* - When defer is `true`, event data is queued (with a timestamp), so that it
* can be sent to GA once externally indicated to be ready. This relies on
* the `qt` or `queueTime` parameter of the Measurement Protocol.
*
* @see https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#qt
*/
export class DeferredCapture {
/**
* Queue of deferred hits to be processed when ready.
*/
private queue: Hit[] = [];
/**
* Marker indicating when it's okay to revert to synchronous capture.
*/
private doneDeferring = false;
/**
* Whether or not deferred capture is desired.
*/
private defer: boolean;
/**
* Holds a reference to the internal promise's resolver. When called, it will
* begin processing hits in the queue.
*/
private isReady: () => void = () => {};
constructor({ defer = false }: { defer: boolean }) {
this.defer = defer;
// Set up a readiness promise that, when resolved from the outside, goes
// through all queued hits and sends them.
new Promise<void>(resolve => {
this.isReady = resolve;
}).then(() => {
this.queue.forEach(this.sendDeferred);
});
}
/**
* Indicates that deferred capture may now proceed.
*/
setReady() {
if (!this.doneDeferring) {
this.isReady();
this.doneDeferring = true;
}
}
/**
* Either forwards the pageview directly to GA, or (if configured) enqueues
* the pageview hit to be captured when ready.
*/
pageview(path: string, metadata: ReactGA.FieldsObject = {}) {
if (this.shouldDefer()) {
this.queue.push({
timestamp: Date.now(),
data: {
hitType: 'pageview',
page: path,
...metadata,
},
});
return;
}
ReactGA.send({
hitType: 'pageview',
page: path,
...metadata,
});
}
/**
* Either forwards the event directly to GA, or (if configured) enqueues the
* event hit to be captured when ready.
*/
event(eventDetails: ReactGA.EventArgs) {
if (this.shouldDefer()) {
this.queue.push({
timestamp: Date.now(),
data: {
...eventDetails,
hitType: 'event',
},
});
return;
}
ReactGA.event(eventDetails);
}
/**
* Only defer if configured and if we are still not ready.
*/
private shouldDefer() {
return this.defer && !this.doneDeferring;
}
/**
* Sends a given hit to GA, decorated with the correct queue time.
*/
private sendDeferred(hit: Hit) {
// Send the hit with the appropriate queue time (`qt`).
ReactGA.send({
...hit.data,
queueTime: Date.now() - hit.timestamp,
});
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* 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.
*/
export { DeferredCapture } from './DeferredCapture';