Analytics Module to support Google Analytics 4

Remove virtual page view. Handle events by send those directly to react-ga4 with hitType of event. That allows to add custom parameters in the event. Added allowedContexts and allowedAttributes config.
rename trackingId to measurementId
rename trackingId to measurementId
generate api-report
fix for vale spelling checks

Signed-off-by: sriram ramakrishnan <sramakr@gmail.com>
This commit is contained in:
Ramakrishnan, Sriram
2023-02-28 08:45:14 -05:00
committed by sriram ramakrishnan
parent ae186b71f4
commit 22b46f7f56
16 changed files with 1352 additions and 0 deletions
@@ -0,0 +1,419 @@
import { ConfigReader } from '@backstage/config';
import { IdentityApi } from '@backstage/core-plugin-api';
import ReactGA from 'react-ga4';
import { GoogleAnalytics4 } from './GoogleAnalytics4';
import { UaEventOptions } from 'react-ga4/types/ga4';
const fnEvent = jest.spyOn(ReactGA, 'event');
fnEvent.mockImplementation(
// @ts-ignore
(optionsOrName: string | UaEventOptions, params?: any) => {
return;
},
);
const fnSet = jest.spyOn(ReactGA, 'set');
// @ts-ignore
fnSet.mockImplementation((fieldObject: any) => {
return;
});
const fnSend = jest.spyOn(ReactGA, 'send');
// @ts-ignore
fnSend.mockImplementation((fieldObject: any) => {
return;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('GoogleAnalytics4', () => {
const context = {
extension: 'App',
pluginId: 'some-plugin',
routeRef: 'unknown',
releaseNum: 1337,
};
const measurementId = 'G-000000-0';
const basicValidConfig = new ConfigReader({
app: { analytics: { ga4: { measurementId: measurementId, testMode: true } } },
});
describe('fromConfig', () => {
it('throws when missing measurementId', () => {
const config = new ConfigReader({ app: { analytics: { ga4: {} } } });
expect(() => GoogleAnalytics4.fromConfig(config)).toThrow(
/Missing required config value/,
);
});
it('returns implementation', () => {
const api = GoogleAnalytics4.fromConfig(basicValidConfig);
expect(api.captureEvent).toBeDefined();
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/',
});
});
});
describe('integration', () => {
const searchConfig = new ConfigReader({
app: {
analytics: {
ga4: {
measurementId: measurementId,
testMode: true,
virtualSearchPageView: {
mode: 'both',
searchQuery: 'term',
},
},
},
},
});
const configWithContentGrouping = new ConfigReader({
app: {
analytics: {
ga4: {
measurementId: measurementId,
testMode: true,
contentGrouping: 'pluginId',
},
},
},
});
const advancedConfig = new ConfigReader({
app: {
analytics: {
ga4: {
measurementId: measurementId,
testMode: true,
allowedContexts: ['pluginId', 'releaseNum'],
allowedAttributes: ['extraDimension', 'extraMetric'],
},
},
},
});
it('testing content grouping', () => {
const api = GoogleAnalytics4.fromConfig(configWithContentGrouping);
api.captureEvent({
action: 'navigate',
subject: '/a-page',
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/a-page',
content_group: context.pluginId,
});
});
it('tracks search', () => {
const api = GoogleAnalytics4.fromConfig(searchConfig);
const expectedAction = 'search';
const expectedLabel = 'search-term';
const expectedValue = 42;
api.captureEvent({
action: expectedAction,
subject: expectedLabel,
value: expectedValue,
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'event',
eventAction: 'search',
eventCategory: 'App',
eventLabel: 'search-term',
eventValue: 42,
search_term: 'search-term',
});
});
it('tracks basic event', () => {
const api = GoogleAnalytics4.fromConfig(basicValidConfig);
const expectedAction = 'click';
const expectedLabel = 'on something';
const expectedValue = 42;
api.captureEvent({
action: expectedAction,
subject: expectedLabel,
value: expectedValue,
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'event',
eventCategory: context.extension,
eventAction: expectedAction,
eventLabel: expectedLabel,
eventValue: expectedValue,
});
});
it('captures configured custom dimensions/metrics on pageviews', () => {
const api = GoogleAnalytics4.fromConfig(advancedConfig);
api.captureEvent({
action: 'navigate',
subject: '/a-page',
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/a-page',
c_pluginId: context.pluginId,
c_releaseNum: context.releaseNum,
});
});
it('captures configured custom dimensions/metrics on events', () => {
const api = GoogleAnalytics4.fromConfig(advancedConfig);
const expectedAction = 'search';
const expectedLabel = 'some query';
const expectedValue = 5;
api.captureEvent({
action: expectedAction,
subject: expectedLabel,
value: expectedValue,
attributes: {
extraDimension: false,
extraMetric: 0,
},
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'event',
eventCategory: context.extension,
eventAction: expectedAction,
eventLabel: expectedLabel,
eventValue: expectedValue,
c_pluginId: context.pluginId,
c_releaseNum: context.releaseNum,
search_term: expectedLabel,
});
});
it('does not pass non-numeric data on metrics', () => {
const api = GoogleAnalytics4.fromConfig(advancedConfig);
api.captureEvent({
action: 'verb',
subject: 'noun',
attributes: {
extraMetric: 'not a number',
},
context,
});
expect(fnEvent).not.toHaveBeenCalledWith({
eventCategory: context.extension,
eventAction: 'verb',
eventLabel: 'noun',
c_pluginId: context.pluginId,
c_releaseNum: context.releaseNum,
c_extraMetric: 'not a number',
});
});
});
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 = GoogleAnalytics4.fromConfig(basicValidConfig, {
identityApi,
});
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setTimeout(resolve));
// There should not have been a UserID set.
expect(fnSet).not.toHaveBeenCalled();
});
it('sets hashed userId when identityApi is provided', async () => {
// Instantiate with identityApi and identity set to optional
const optionalConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'optional' },
},
},
});
const api = GoogleAnalytics4.fromConfig(optionalConfig, { identityApi });
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setTimeout(resolve));
expect(fnSet).toHaveBeenCalledTimes(1);
expect(fnSet).toHaveBeenCalledWith({
// String indicating userEntityRef went through expected hashing.
user_id: '557365723a64656661756c742f736f6d656f6e65',
});
});
it('set custom-hashed userId when userIdTransform is provided', async () => {
const userIdTransform = jest.fn().mockResolvedValue('s0m3hash3dvalu3');
const optionalConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'optional' },
},
},
});
const api = GoogleAnalytics4.fromConfig(optionalConfig, {
identityApi,
userIdTransform,
});
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setTimeout(resolve));
// User ID should have been set after the pageview.
expect(fnSet).toHaveBeenCalledWith({
user_id: 's0m3hash3dvalu3',
});
expect(userIdTransform).toHaveBeenCalledWith('User:default/someone');
});
it('does not set userId when identityApi is provided and ga4.identity is explicitly disabled', async () => {
// Instantiate with identityApi and identity explicitly disabled.
const disabledConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'disabled' },
},
},
});
const api = GoogleAnalytics4.fromConfig(disabledConfig, { identityApi });
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setTimeout(resolve));
// A pageview should have been fired immediately.
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/',
});
// There should not have been a UserID set.
expect(fnSet).toHaveBeenCalledTimes(0);
});
it('throws error when ga4.identity is required but no identityApi is provided', async () => {
// Instantiate without identityApi and identity explicitly disabled.
const requiredConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'required' },
},
},
});
expect(() => GoogleAnalytics4.fromConfig(requiredConfig)).toThrow();
});
it('defers event capture when ga4.identity is required', async () => {
// Instantiate with identityApi and identity explicitly required.
const requiredConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'required' },
},
},
});
const api = GoogleAnalytics4.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 => setTimeout(resolve));
// User ID should have been set first.
expect(fnSet).toHaveBeenCalledWith({
// String indicating userEntityRef went through expected hashing.
user_id: '557365723a64656661756c742f736f6d656f6e65',
});
// Then a pageview should have been fired with a queue time.
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/',
timestamp_micros: expect.any(Number),
});
// Then an event should have been fired with a queue time.
expect(fnSend).toHaveBeenCalledWith({
hitType: 'event',
timestamp_micros: expect.any(Number),
eventAction: 'test',
eventCategory: 'App',
eventLabel: 'some label',
eventValue: undefined,
});
// And subsequent hits should not have a queue time.
api.captureEvent({
action: 'navigate',
subject: '/page-2',
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/page-2',
});
});
});
});
@@ -0,0 +1,252 @@
import ReactGA from 'react-ga4';
import {
AnalyticsApi,
AnalyticsContextValue,
AnalyticsEventAttributes,
AnalyticsEvent,
IdentityApi,
} from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
import { DeferredCapture } from '../../../util';
/**
* Google Analytics API provider for the Backstage Analytics API.
* @public
*/
export class GoogleAnalytics4 implements AnalyticsApi {
private readonly customUserIdTransform?: (
userEntityRef: string,
) => Promise<string>;
private readonly capture: DeferredCapture;
private readonly contentGroupBy?: string;
private readonly allowedContexts?: string[];
private readonly allowedAttributes?: string[];
/**
* Instantiate the implementation and initialize ReactGA.
* @param options initializes Google Analytics module with the config
*/
private constructor(options: {
identityApi?: IdentityApi;
userIdTransform?: 'sha-256' | ((userEntityRef: string) => Promise<string>);
identity: string;
measurementId: string;
testMode: boolean;
debug: boolean;
contentGroupBy?: string;
allowedContexts?: string[];
allowedAttributes?: string[];
}) {
const {
identity,
measurementId,
identityApi,
userIdTransform = 'sha-256',
testMode,
debug,
contentGroupBy,
allowedContexts,
allowedAttributes,
} = options;
// Initialize Google Analytics.
ReactGA.initialize(measurementId, {
testMode,
gaOptions: {
debug_mode: debug,
},
gtagOptions: {
debug_mode: debug,
},
});
this.contentGroupBy = contentGroupBy;
this.allowedContexts = allowedContexts;
this.allowedAttributes = allowedAttributes;
// If identity is required, defer event capture until identity is known.
this.capture = new DeferredCapture({ defer: identity === 'required' });
// Allow custom userId transformation.
this.customUserIdTransform =
typeof userIdTransform === 'function' ? userIdTransform : undefined;
// Capture user only when explicitly enabled and provided.
if (identity !== 'disabled') {
if (identityApi) {
this.setUserFrom(identityApi).then(() => {
return;
});
}
}
}
/**
* Instantiate a fully configured GA Analytics API implementation.
* @param config - Config object from app config
* @param options - options with identityApi and userIdTransform config
*/
static fromConfig(
config: Config,
options: {
identityApi?: IdentityApi;
userIdTransform?:
| 'sha-256'
| ((userEntityRef: string) => Promise<string>);
} = {},
) {
// Get all necessary configuration.
const measurementId = config.getString('app.analytics.ga4.measurementId');
const identity =
config.getOptionalString('app.analytics.ga4.identity') || 'disabled';
const debug = config.getOptionalBoolean('app.analytics.ga4.debug') ?? false;
const testMode =
config.getOptionalBoolean('app.analytics.ga4.testMode') ?? false;
const contentGroupBy = config.getOptionalString(
'app.analytics.ga4.contentGrouping',
);
const allowedContexts = config.getOptionalStringArray(
'app.analytics.ga4.allowedContexts',
);
const allowedAttributes = config.getOptionalStringArray(
'app.analytics.ga4.allowedAttributes',
);
if (identity === 'required' && !options.identityApi) {
throw new Error(
'Invalid config: identity API must be provided to deps when ga4.identity is required',
);
}
// Return an implementation instance.
return new GoogleAnalytics4({
...options,
identity,
measurementId: measurementId,
testMode,
debug,
contentGroupBy,
allowedContexts,
allowedAttributes,
});
}
/**
* Primary event capture implementation. Handles core navigate event as a
* pageview and the rest as custom events. All custom dimensions/metrics are
* applied as they should be (set on pageview, merged object on events).
* @param event - AnalyticsEvent type captured
*/
captureEvent(event: AnalyticsEvent) {
const { context, action, subject, value, attributes } = event;
const customEventData = this.setEventParameters(context, attributes);
if (this.contentGroupBy) {
customEventData.content_group = context[this.contentGroupBy]!;
}
if (action === 'navigate' && context.extension === 'App') {
this.capture.pageview(subject, customEventData);
return;
}
if (action === 'search') {
customEventData.search_term = subject;
}
this.capture.event(
{
category: context.extension || 'App',
action,
label: subject,
value,
},
customEventData,
);
}
/**
* Returns an object of dimensions/metrics given an Analytics Context and an
* Event Attributes, e.g. { c_pluginId: "some value", a_attribute1: 42 }
* @param context analytics context object
* @param attributes additional analytics event attributes
*/
private setEventParameters(
context: AnalyticsContextValue,
attributes: AnalyticsEventAttributes = {},
) {
const customEventParameters: {
[x: string]: string | number | boolean | undefined;
} = {};
this.allowedContexts?.forEach(ctx => {
if (context[ctx]) {
customEventParameters[`c_${ctx}`] = context[ctx];
}
});
this.allowedAttributes?.forEach(attr => {
if (attributes[attr]) {
customEventParameters[`a_${attr}`] = attributes[attr];
}
});
return customEventParameters;
}
/**
* 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 `userIdTransform`
* function alongside the `identityApi` to `GoogleAnalytics.fromConfig()`.
* This function receives the `userEntityRef` as an argument and should
* resolve to a hashed version of whatever identifier they choose.
*
* Note: this feature requires that an integrator has set up a Google
* Analytics User ID view in the property used to track Backstage.
* @param identityApi IdentityApi object
*/
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({ user_id: userId });
// Notify the deferred capture mechanism that it may proceed.
this.capture.setReady();
}
/**
* Returns a PII-free (according to Google's terms of service) user ID for
* use in Google Analytics.
* @param userEntityRef user entity as string
*/
private getPrivateUserId(userEntityRef: string): Promise<string> {
// Allow integrators to provide their own hashing transformer.
if (this.customUserIdTransform) {
return this.customUserIdTransform(userEntityRef);
}
return this.hash(userEntityRef);
}
/**
* Simple hash function; relies on web cryptography + the sha-256 algorithm.
* @param value value to be hashed
*/
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('');
}
}
@@ -0,0 +1 @@
export { GoogleAnalytics4 } from './GoogleAnalytics4';
@@ -0,0 +1,2 @@
export { analyticsModuleGA4 } from './plugin';
export * from './apis/implementations/AnalyticsApi';
@@ -0,0 +1,22 @@
/*
* Copyright 2021 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 { analyticsModuleGA4 } from './plugin';
describe('google-analytics', () => {
it('should export plugin', () => {
expect(analyticsModuleGA4).toBeDefined();
});
});
@@ -0,0 +1,26 @@
/*
* Copyright 2021 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 { createPlugin } from '@backstage/core-plugin-api';
/**
* @deprecated Importing and including this plugin in an app has no effect.
* This will be removed in a future release.
*
* @public
*/
export const analyticsModuleGA4 = createPlugin({
id: 'analytics-provider-ga4',
});
@@ -0,0 +1,34 @@
/*
* Copyright 2021 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 '@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,112 @@
import ReactGA from 'react-ga4';
import { UaEventOptions } from 'react-ga4/types/ga4';
type Hit = {
data: {
hitType: 'pageview' | 'event';
[x: string]: any;
};
};
const PageViewEvent = 'pageview';
/**
* 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. When undefined, hits
* can safely be sent without delay.
*/
private queue: Hit[] | undefined;
/**
* constructor for creating the DeferredCapture object
* @param defer type of {defer: boolean}
*/
constructor({ defer = false }: { defer: boolean }) {
this.queue = defer ? [] : undefined;
}
/**
* Indicates that deferred capture may now proceed.
*/
setReady() {
if (this.queue) {
this.queue.forEach(this.sendDeferred);
this.queue = undefined;
}
}
/**
* Either forwards the pageview directly to GA, or (if configured) enqueues
* the pageview hit to be captured when ready.
* @param path pageview path
* @param metadata any object that can be passed as additional parameter to the event
*/
pageview(path: string, metadata: any = {}) {
if (this.queue) {
this.queue.push({
data: {
hitType: PageViewEvent,
timestamp_micros: Date.now() * 1000,
page: path,
...metadata,
},
});
return;
}
ReactGA.send({
hitType: PageViewEvent,
page: path,
...metadata,
});
}
/**
* Either forwards the event directly to GA, or (if configured) enqueues the
* event hit to be captured when ready.
* @param eventDetails type of UaEventOptions object
* @param metadata any object that can be passed as additional parameter to the event
*/
event(eventDetails: UaEventOptions, metadata: any = {}) {
const data = {
hitType: 'event',
eventCategory: eventDetails.category,
eventLabel: eventDetails.label!,
eventAction: eventDetails.action,
eventValue: eventDetails.value,
...metadata,
};
if (this.queue) {
this.queue.push({
data: {
...data,
timestamp_micros: Date.now() * 1000,
},
});
return;
}
ReactGA.send(data);
}
/**
* Sends a given hit to GA, decorated with the correct queue time.
* @param hit Hit object
*/
private sendDeferred(hit: Hit) {
// Send the hit with the appropriate queue time (`qt`).
ReactGA.send({
...hit.data,
});
}
}
@@ -0,0 +1 @@
export { DeferredCapture } from './DeferredCapture';