Merge pull request #16642 from sramak1396/master
Analytics Module to support Google Analytics 4
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-analytics-module-ga4': minor
|
||||
---
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1 @@
|
||||
# @backstage/plugin-analytics-module-ga4
|
||||
@@ -0,0 +1,221 @@
|
||||
# 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 @backstage/plugin-analytics-module-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 '@backstage/plugin-analytics-module-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 GA4 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 recommend configuring [a content grouping](#enabling-content-grouping).
|
||||
Additional dimensional data can be captured using custom dimensions, like this:
|
||||
|
||||
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_`.
|
||||
5. `allowedContexts` and `allowedAttributes` are optional, if not provided, no additional context and attributes will be sent.
|
||||
6. if `allowedContexts` or `allowedAttributes` is set to '\*', all context and attributes will be sent.
|
||||
|
||||
```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:
|
||||
|
||||
1. Clone the main Backstage monorepo `git clone git@github.com:backstage/backstage.git`
|
||||
2. Install all dependencies `yarn install`
|
||||
3. If one does not exist, create an `app-config.local.yaml` file in the root of
|
||||
the monorepo and add config for this plugin (see below)
|
||||
4. Enter this plugin's working directory: `cd plugins/analytics-provider-ga4`
|
||||
5. Start the plugin in isolation: `yarn start`
|
||||
6. Navigate to the playground page at `http://localhost:3000/ga4`
|
||||
7. Open the web console to see events fire when you navigate or when you
|
||||
interact with instrumented components.
|
||||
|
||||
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,26 @@
|
||||
## 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 { Config } from '@backstage/config';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
|
||||
// @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)
|
||||
```
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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?: 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?: string[] | ['*'];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 React from 'react';
|
||||
import { Link } from '@backstage/core-components';
|
||||
|
||||
export const Playground = () => {
|
||||
return (
|
||||
<>
|
||||
<Link to="#clicked">Click Here</Link>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { Playground } from './Playground';
|
||||
|
||||
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',
|
||||
});
|
||||
createDevApp()
|
||||
.registerPlugin(analyticsModuleGA4)
|
||||
.addPage({
|
||||
path: '/ga4',
|
||||
title: 'GA4 Playground',
|
||||
element: <Playground />,
|
||||
})
|
||||
.render();
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@backstage/plugin-analytics-module-ga4",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"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",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"clean": "backstage-cli package clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"react-ga4": "^2.0.0",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0",
|
||||
"react-dom": "^16.13.1 || ^17.0.0",
|
||||
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/dom": "^8.0.0",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/jest": "^28.1.3",
|
||||
"@types/node": "^16.11.26",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"msw": "^1.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
* Copyright 2023 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 { 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;
|
||||
});
|
||||
|
||||
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(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
label: '/',
|
||||
category: 'App',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const allowAllContextsAndAttrsConfig = new ConfigReader({
|
||||
app: {
|
||||
analytics: {
|
||||
ga4: {
|
||||
measurementId: measurementId,
|
||||
testMode: true,
|
||||
allowedContexts: ['*'],
|
||||
allowedAttributes: ['*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('testing content grouping', () => {
|
||||
const api = GoogleAnalytics4.fromConfig(configWithContentGrouping);
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/a-page',
|
||||
context,
|
||||
});
|
||||
|
||||
expect(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
label: '/a-page',
|
||||
category: 'App',
|
||||
value: undefined,
|
||||
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(fnEvent).toHaveBeenCalledWith('search', {
|
||||
action: 'search',
|
||||
category: 'App',
|
||||
label: 'search-term',
|
||||
value: 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(fnEvent).toHaveBeenCalledWith('click', {
|
||||
action: 'click',
|
||||
category: context.extension,
|
||||
label: 'on something',
|
||||
value: expectedValue,
|
||||
});
|
||||
});
|
||||
|
||||
it('captures configured custom dimensions/metrics on pageviews', () => {
|
||||
const api = GoogleAnalytics4.fromConfig(advancedConfig);
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/a-page',
|
||||
context,
|
||||
});
|
||||
|
||||
expect(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
label: '/a-page',
|
||||
category: 'App',
|
||||
value: undefined,
|
||||
c_pluginId: context.pluginId,
|
||||
c_releaseNum: context.releaseNum,
|
||||
});
|
||||
});
|
||||
|
||||
it('captures all dimensions/metrics on pageviews', () => {
|
||||
const api = GoogleAnalytics4.fromConfig(allowAllContextsAndAttrsConfig);
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/a-page',
|
||||
context,
|
||||
attributes: {
|
||||
'attr-1': 'attr-value-1',
|
||||
'attr-2': 'attr-value-2',
|
||||
},
|
||||
});
|
||||
expect(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
category: 'App',
|
||||
label: '/a-page',
|
||||
value: undefined,
|
||||
c_pluginId: context.pluginId,
|
||||
c_releaseNum: context.releaseNum,
|
||||
c_routeRef: context.routeRef,
|
||||
c_extension: context.extension,
|
||||
'a_attr-1': 'attr-value-1',
|
||||
'a_attr-2': 'attr-value-2',
|
||||
});
|
||||
});
|
||||
|
||||
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(fnEvent).toHaveBeenCalledWith('search', {
|
||||
action: 'search',
|
||||
category: context.extension,
|
||||
label: expectedLabel,
|
||||
value: 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({
|
||||
category: context.extension,
|
||||
action: 'verb',
|
||||
label: '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(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
label: '/',
|
||||
category: 'App',
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
// 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(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
label: '/',
|
||||
timestamp_micros: expect.any(Number),
|
||||
value: undefined,
|
||||
category: 'App',
|
||||
});
|
||||
|
||||
// Then an event should have been fired with a queue time.
|
||||
expect(fnEvent).toHaveBeenCalledWith('test', {
|
||||
action: 'test',
|
||||
timestamp_micros: expect.any(Number),
|
||||
label: 'some label',
|
||||
category: 'App',
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
// And subsequent hits should not have a queue time.
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/page-2',
|
||||
context,
|
||||
});
|
||||
|
||||
expect(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
label: '/page-2',
|
||||
category: 'App',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* Copyright 2023 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-ga4';
|
||||
import {
|
||||
AnalyticsApi,
|
||||
AnalyticsContextValue,
|
||||
AnalyticsEventAttributes,
|
||||
AnalyticsEvent,
|
||||
IdentityApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DeferredCapture } from '../../../util/DeferredCapture';
|
||||
|
||||
/**
|
||||
* 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.allowedAttributes = allowedAttributes;
|
||||
this.allowedContexts = allowedContexts;
|
||||
// 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.event(
|
||||
{
|
||||
category: context.extension || 'App',
|
||||
action: 'page_view',
|
||||
label: subject,
|
||||
value,
|
||||
},
|
||||
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;
|
||||
} = {};
|
||||
|
||||
const contextKeys =
|
||||
this.allowedContexts?.join('') === '*'
|
||||
? Object.keys(context)
|
||||
: this.allowedContexts;
|
||||
|
||||
contextKeys?.forEach(ctx => {
|
||||
if (context[ctx]) {
|
||||
customEventParameters[`c_${ctx}`] = context[ctx];
|
||||
}
|
||||
});
|
||||
|
||||
const attrKeys =
|
||||
this.allowedAttributes?.join('') === '*'
|
||||
? Object.keys(attributes)
|
||||
: this.allowedAttributes;
|
||||
attrKeys?.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 window.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,16 @@
|
||||
/*
|
||||
* Copyright 2023 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 { GoogleAnalytics4 } from './GoogleAnalytics4';
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2023 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 * from './apis/implementations/AnalyticsApi';
|
||||
@@ -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,96 @@
|
||||
/*
|
||||
* Copyright 2023 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-ga4';
|
||||
|
||||
import { UaEventOptions } from 'react-ga4/types/ga4';
|
||||
|
||||
type Hit = {
|
||||
hitType: string;
|
||||
data: {
|
||||
[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. 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 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 = {
|
||||
...eventDetails,
|
||||
...metadata,
|
||||
};
|
||||
if (this.queue) {
|
||||
this.queue.push({
|
||||
hitType: eventDetails.action,
|
||||
data: {
|
||||
...data,
|
||||
timestamp_micros: Date.now() * 1000,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
ReactGA.event(eventDetails.action, 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.event(hit.hitType, {
|
||||
...hit.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2023 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';
|
||||
@@ -4468,6 +4468,36 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-analytics-module-ga4@workspace:plugins/analytics-module-ga4":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-analytics-module-ga4@workspace:plugins/analytics-module-ga4"
|
||||
dependencies:
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/core-app-api": "workspace:^"
|
||||
"@backstage/core-components": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/theme": "workspace:^"
|
||||
"@testing-library/dom": ^8.0.0
|
||||
"@testing-library/jest-dom": ^5.10.1
|
||||
"@testing-library/react": ^12.1.3
|
||||
"@testing-library/user-event": ^14.0.0
|
||||
"@types/jest": ^28.1.3
|
||||
"@types/node": ^16.11.26
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
cross-fetch: ^3.1.5
|
||||
msw: ^1.0.0
|
||||
react-ga4: ^2.0.0
|
||||
react-use: ^17.2.4
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0
|
||||
react-dom: ^16.13.1 || ^17.0.0
|
||||
react-router-dom: 6.0.0-beta.0 || ^6.3.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-analytics-module-ga@workspace:plugins/analytics-module-ga":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-analytics-module-ga@workspace:plugins/analytics-module-ga"
|
||||
@@ -34528,6 +34558,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-ga4@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "react-ga4@npm:2.1.0"
|
||||
checksum: f7fb41141418d4ad14756f1126a1e9958db37d4d84ae6cd798043dc03a390b6dba74d69311af0349f0b9580a43bda8930138194ccc29c4100efe446e2d6eb057
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-ga@npm:^3.3.0":
|
||||
version: 3.3.1
|
||||
resolution: "react-ga@npm:3.3.1"
|
||||
|
||||
Reference in New Issue
Block a user