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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-analytics-module-ga4': major
---
Plugin provides Backstage Analytics API for Google Analytics 4. Once installed and configured, analytics events will be sent to GA4 as your users navigate and use your Backstage instance
+47
View File
@@ -0,0 +1,47 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
plugins: ['jsdoc'],
rules: {
'jsdoc/check-access': 1,
'jsdoc/check-alignment': 1,
'jsdoc/check-param-names': ['warn', { checkDestructured: false }],
'jsdoc/check-property-names': 1,
'jsdoc/check-tag-names': ['warn', { definedTags: ['visibility'] }],
'jsdoc/check-types': 1,
'jsdoc/check-values': 1,
'jsdoc/empty-tags': 1,
'jsdoc/implements-on-classes': 1,
'jsdoc/multiline-blocks': 1,
'jsdoc/no-multi-asterisks': 1,
'jsdoc/no-types': 1,
'jsdoc/no-undefined-types': 1,
'jsdoc/require-asterisk-prefix': 1,
'jsdoc/require-description': 1,
'jsdoc/require-jsdoc': [
'warn',
{
publicOnly: true,
require: {
FunctionDeclaration: true,
MethodDefinition: true,
ClassDeclaration: true,
ArrowFunctionExpression: true,
FunctionExpression: true,
},
contexts: ['ExportNamedDeclaration > VariableDeclaration'],
},
],
'jsdoc/require-param': ['warn', { checkDestructured: false }],
'jsdoc/require-param-description': 1,
'jsdoc/require-param-name': 1,
'jsdoc/require-property': 1,
'jsdoc/require-property-description': 1,
'jsdoc/require-property-name': 1,
'jsdoc/require-property-type': 1,
'jsdoc/valid-types': 1,
},
settings: {
jsdoc: {
tagNamePreference: { 'tag constructor': 'constructor' },
},
},
});
@@ -0,0 +1 @@
# @backstage/plugin-analytics-module-ga4
+214
View File
@@ -0,0 +1,214 @@
# Analytics Module: Google Analytics 4
This plugin provides an opinionated implementation of the Backstage Analytics
API for Google Analytics 4. Once installed and configured, analytics events will
be sent to GA as your users navigate and use your Backstage instance.
This plugin contains no other functionality.
## Installation
1. Install the plugin package in your Backstage app:
`cd packages/app && yarn add @devx-discover/plugin-analytics-ga4`
2. Wire up the API implementation to your App:
```tsx
// packages/app/src/apis.ts
import {
analyticsApiRef,
configApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { GoogleAnalytics4 } from '@devx-discover/plugin-analytics-ga4';
export const apis: AnyApiFactory[] = [
// Instantiate and register the GA Analytics API Implementation.
createApiFactory({
api: analyticsApiRef,
deps: { configApi: configApiRef, identityApi: identityApiRef },
factory: ({ configApi, identityApi }) =>
GoogleAnalytics4.fromConfig(configApi, {
identityApi,
}),
}),
];
```
3. Configure the plugin in your `app-config.yaml`:
The following is the minimum configuration required to start sending analytics
events to GA. All that's needed is your Universal Analytics measurement ID:
```yaml
# app-config.yaml
app:
analytics:
ga4:
measurementId: G-0000000-0
```
4. Update CSP in your `app-config.yaml`:
The following is the minimal content security policy required to load scripts from GA.
```yaml
backend:
csp:
connect-src: ["'self'", 'http:', 'https:']
# Add these two lines below
script-src: ["'self'", "'unsafe-eval'", 'https://www.google-analytics.com']
img-src: ["'self'", 'data:', 'https://www.google-analytics.com']
```
## Configuration
In order to be able to analyze usage of your Backstage instance _by plugin_, we
strongly recommend configuring at least one [custom dimension][what-is-a-custom-dimension]
to capture Plugin IDs associated with events, including page views.
1. First, [configure the custom dimension in GA] [configure-custom-dimension].
Be sure to set the Scope to `Event`, and name it `dimension1`.
2. Then, add a mapping to your `app.analytics.ga4` configuration that instructs
the plugin to capture Plugin IDs on the custom dimension you just created.
It should look like this:
3. `allowedContexts` config accepts array of string, where each entry is a context parameter that will be sent in the event.
context names will be prefixed by `c_`.
4. `allowedAttributes` config accepts array of string, where each entry is an attribute that will be sent in the event.
attribute names will be prefixed by `a_`.
```yaml
app:
analytics:
ga4:
measurementId: G-0000000-0
allowedContexts: [ 'pluginId']
```
```yaml
app:
analytics:
ga4:
allowedContexts: [ 'pluginId']
allowedAttributes: ['someEventContextAttr']
```
### User IDs
This plugin supports accurately deriving user-oriented metrics (like monthly
active users) using Google Analytics' [user ID views][ga-user-id-view]. To
enable this...
1. Be sure you've gone through the process of setting up a user ID view in your
Backstage instance's Google Analytics property (see docs linked above).
2. Make sure you instantiate `GoogleAnalytics` with an `identityApi` instance
passed to it, as shown in the installation section above.
3. Set `app.analytics.ga4.identity` to either `required` or `optional` in your
`app.config.yaml`, like this:
```yaml
app:
analytics:
ga4:
measurementId: G-0000000-0
identity: optional
```
Set `identity` to `optional` if you need accurate session counts, including
cases where users do not sign in at all. Use `required` if you need all hits
to be associated with a user ID without exception (and don't mind if some
sessions are not captured, such as those where no sign in occur).
Note that, to comply with GA policies, the value of the User ID is
pseudonymized before being sent to GA. By default, it is a `sha256` hash of the
current user's `userEntityRef` as returned by the `identityApi`. To set a
different value, provide a `userIdTransform` function alongside `identityApi`
when you instantiate `GoogleAnalytics`. This function will be passed the
`userEntityRef` as an argument and should resolve to the value you wish to set
as the user ID. For example:
```typescript
import {
analyticsApiRef,
configApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga';
export const apis: AnyApiFactory[] = [
createApiFactory({
api: analyticsApiRef,
deps: { configApi: configApiRef, identityApi: identityApiRef },
factory: ({ configApi, identityApi }) =>
GoogleAnalytics4.fromConfig(configApi, {
identityApi,
userIdTransform: async (userEntityRef: string): Promise<string> => {
return customHashingFunction(userEntityRef);
},
}),
}),
];
```
### Enabling content grouping
Content groups enable you to categorize pages and screens into custom buckets which you can see
metrics for related groups of information.
More about content grouping here [content groups][content-grouping].
It's recommended to enable content grouping by PluginId. `contentGrouping` supports `routeRef` and extension.
```yaml
app:
analytics:
ga4:
contentGrouping: pluginId
```
Please note, content grouping takes 24hrs to show up in the Google Analytics dashboard.
### Debugging and Testing
In pre-production environments, you may wish to set additional configurations
to turn off reporting to Analytics and/or print debug statements to the
console. You can do so like this:
```yaml
app:
analytics:
ga4:
testMode: true # Prevents data being sent to GA
debug: true # Logs analytics event to the web console
```
You might commonly set the above in an `app-config.local.yaml` file, which is
normally `gitignore`'d but loaded and merged in when Backstage is bootstrapped.
## Development
If you would like to contribute improvements to this plugin, the easiest way to
make and test changes is to do the following:
See the [Developer documentation](development.md) for instructions on how to get started developing this plugin.
Code for the isolated version of the plugin can be found inside the [/dev](./dev)
directory. Changes to the plugin are hot-reloaded.
#### Recommended Dev Config
Paste this into your `app-config.local.yaml` while developing this plugin:
```yaml
app:
analytics:
ga4:
measurementId: G-0000000-0
debug: true
testMode: true
allowedContexts: [ 'pluginId']
```
[what-is-a-custom-dimension]: https://support.google.com/analytics/answer/2709828
[configure-custom-dimension]: https://support.google.com/analytics/answer/10075209?hl=en#
[ga-user-id-view]: https://support.google.com/analytics/answer/3123669
[content-grouping]: https://support.google.com/analytics/answer/11523339?hl=en
@@ -0,0 +1,30 @@
## API Report File for "@backstage/plugin-analytics-module-ga4"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnalyticsApi } from '@backstage/core-plugin-api';
import { AnalyticsEvent } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
import { IdentityApi } from '@backstage/core-plugin-api';
// @public @deprecated (undocumented)
export const analyticsModuleGA4: BackstagePlugin<{}, {}, {}>;
// @public
export class GoogleAnalytics4 implements AnalyticsApi {
captureEvent(event: AnalyticsEvent): void;
static fromConfig(
config: Config,
options?: {
identityApi?: IdentityApi;
userIdTransform?:
| 'sha-256'
| ((userEntityRef: string) => Promise<string>);
},
): GoogleAnalytics4;
}
// (No @packageDocumentation comment for this package)
```
+128
View File
@@ -0,0 +1,128 @@
/*
* Copyright 2020 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 interface Config {
app: {
// TODO: Only marked as optional because backstage-cli config:check in the
// context of the monorepo is too strict. Ideally, this would be marked as
// required.
analytics?: {
ga4: {
/**
* Google Analytics measurement ID, e.g. G-000000-0
* @visibility frontend
*/
measurementId: string;
/**
* Controls how the identityApi is used when sending data to GA:
*
* - `disabled`: (Default) Explicitly prevents a user's identity from
* being used when capturing events in GA.
* - `optional`: Pageviews and hits are forwarded to GA as they happen
* and only include user identity metadata once known. Guarantees
* that hits are captured for all sessions, even if no sign in
* occurs, but may result in dropped hits in User ID views.
* - `required`: All pageviews and hits are deferred until an identity
* is known. Guarantees that all data sent to GA correlates to a user
* identity, but prevents GA from receiving events for sessions in
* which a user does not sign in. An `identityApi` instance must be
* passed during instantiation when set to this value.
*
* @visibility frontend
*/
identity?: 'disabled' | 'optional' | 'required';
/**
* Controls whether to send virtual pageviews on `search` events.
* Can be used to enable Site Search in GA.
*/
virtualSearchPageView?: {
/**
* - `disabled`: (Default) no virtual pageviews are sent
* - `only`: Sends virtual pageview _instead_ of the `search` event
* - `both`: Sends both the `search` event _and_ the virtual pageview
* @visibility frontend
*/
mode?: 'disabled' | 'only' | 'both';
/**
* Specifies on which path the main Search page is mounted.
* Defaults to `/search`.
* @visibility frontend
*/
mountPath?: string;
/**
* Specifies which query param is used for the term query in the virtual pageview URL.
* Defaults to `query`.
* @visibility frontend
*/
searchQuery?: string;
/**
* Specifies which query param is used for the category query in the virtual pageview URL.
* Skipped by default.
* @visibility frontend
*/
categoryQuery?: string;
};
/**
* Whether to log analytics debug statements to the console.
* Defaults to false.
*
* @visibility frontend
*/
debug?: boolean;
/**
* Prevents events from actually being sent when set to true. Defaults
* to false.
*
* @visibility frontend
*/
testMode?: boolean;
/**
* Content grouping definition
* Feature available in Google Analytics 4
* More information https://support.google.com/analytics/answer/11523339?hl=en
* Data can be grouped by pluginId, routeRef
* Takes 24 hours before metrics shows up in GA dashboard
* Specifies the dimension to be used for content grouping
* Can be one of pluginId, extension or routeRef
* @visibility frontend
*
*/
contentGrouping?: 'pluginId' | 'extension' | 'routeRef';
/**
* Configuration informing how Analytics Context and Event Attributes
* metadata will be captured in Google Analytics.
* Contexts that will be sent as parameters in the event.
* context-name will be prefixed by c_, for example, pluginId will be c_pluginId in the event.
*
*/
allowedContexts?: Array<string>;
/**
*
* Attributes that will be sent as parameters in the event
* attribute-name will be prefixed by a_, for example , testAttribute will be c_testAttribute in the event.
*
*/
allowedAttributes?: Array<string>;
};
};
};
}
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@backstage/plugin-analytics-module-ga4",
"version": "0.1.19-next.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin-module"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/config": "^1.0.6",
"@backstage/core-components": "^0.12.4",
"@backstage/core-plugin-api": "^1.4.0",
"@backstage/theme": "^0.2.16",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"react-ga4": "^2.0.0",
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.22.3",
"@backstage/core-app-api": "^1.5.0",
"@backstage/dev-utils": "^1.0.12",
"@backstage/test-utils": "^1.2.5",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"cross-fetch": "^3.1.5",
"msw": "^0.44.0"
},
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
@@ -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';