Add analytics-provider-ga plugin

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2021-08-01 18:06:01 +02:00
parent f139fed1ac
commit c582819a01
17 changed files with 820 additions and 1 deletions
@@ -0,0 +1,9 @@
---
'@backstage/plugin-analytics-provider-ga': patch
---
Initial Google Analytics API provider for the Backstage Analytics API, which:
- Handles pageview and custom event instrumentation idiomatically.
- Enables custom dimension and metric collection via app config.
- Includes configurable debug/test mode for non-production environments.
+1
View File
@@ -88,6 +88,7 @@ firehydrant
FireHydrant
Firekube
Fiverr
ga
gitbeaker
GitHub
GitLab
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+131
View File
@@ -0,0 +1,131 @@
# Analytics Provider: Google Analytics
This plugin provides an opinionated implementation of the Backstage Analytics
API for Google Analytics. Once installed and configured, analytics events will
be sent to GA as your users navigate and use your Backstage instance.
This plugin contains no (and will never contain any) other functionality.
## Installation
1. Install this plugin in your Backstage instance: `yarn add @backstage/plugin-analytics-provider-ga`
2. Register the plugin with your App. In most instances of Backstage, this is
as simple as adding `export { analyticsProviderGA } from '@backstage/plugin-analytics-provider-ga';`
to your `packages/app/src/plugins.ts` file.
3. Configure the plugin (see below).
## Configuration
The following is the minimum configuration required to start sending analytics
events to GA. All that's needed is your Universal Analytics tracking ID:
```yaml
# app-config.yaml
app:
analytics:
provider: ga
ga:
trackingId: UA-0000000-0
```
### Plugin Analytics
In order to be able to analyze usage of your Backstage instance _by plugin_, we
strongly recommend configuring at least one 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 `hit`, and name it something like `Plugin`. Note
the index of the dimension you just created (e.g. `1`, if this is the first
custom dimension you've created in your GA property).
2. Then, add a mapping to your `app.analytics.ga` configuration that instructs
the plugin to capture Plugin IDs on the custom dimension you just created.
It should look like this:
```yaml
app:
analytics:
provider: ga
ga:
trackingId: UA-0000000-0
customDimensionsMetrics:
- type: dimension
index: 1
source: domain
attribute: pluginId
```
You can configure additional custom dimension and metric collection by adding
more entries to the `customDimensionsMetrics` array:
```yaml
app:
analytics:
ga:
customDimensionsMetrics:
- type: dimension
index: 1
source: domain
attribute: pluginId
- type: dimension
index: 2
source: domain
attribute: routeRef
- type: metric
index: 1
source: context
attribute: someEventContextAttr
```
### 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:
ga:
testMode: true # Prevents data being sent to GA
debug: true # Logs analytics event to the web console
```
## 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-ga`
5. Start the plugin in isolation: `yarn start`
6. Navigate to the playground page at [/ga](http://localhost:3000/ga)
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:
provider: ga
ga:
trackingId: UA-0000000-0
debug: true
testMode: true
customDimensionsMetrics:
- type: dimension
index: 1
source: domain
attribute: pluginId
```
[configure-custom-dimension]: https://support.google.com/analytics/answer/2709828?hl=en#configuration
+86
View File
@@ -0,0 +1,86 @@
/*
* 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: {
analytics: {
provider: 'ga';
ga: {
/**
* Google Analytics tracking ID, e.g. UA-000000-0
* @visibility frontend
*/
trackingId: string;
/**
* Whether or not 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;
/**
* Configuration informing how Analytics Domain and Event Context
* metadata will be captured in Google Analytics.
*/
customDimensionsMetrics?: Array<{
/**
* Specifies whether the corresponding metadata should be collected
* as a Google Analytics custom dimension or custom metric.
*
* @visibility frontend
*/
type: 'dimension' | 'metric';
/**
* The index of the Google Analytics custom dimension or metric that
* the metadata should be written to.
*
* @visibility frontend
*/
index: number;
/**
* Specifies whether the desired value lives as an attribute on the
* Analytics Domain or the Event's Context.
*
* @visibility frontend
*/
source: 'domain' | 'context';
/**
* The attribute on the domain or context that should be captured.
* e.g. to capture the Plugin ID associated with an event, the source
* should be set to "domain" and the attribute should be set to
* pluginId.
*
* @visibility frontend
*/
attribute: string;
}>;
};
};
};
}
@@ -0,0 +1,29 @@
/*
* 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 { withAnalyticsDomain } from '@backstage/core-plugin-api';
import { Link } from '@backstage/core-components';
const DomainlessPlayground = () => {
return (
<>
<Link to="#clicked">Click Here</Link>
</>
);
};
export const Playground = withAnalyticsDomain(DomainlessPlayground, {});
@@ -0,0 +1,3 @@
app:
analytics:
provider: ga
@@ -0,0 +1,28 @@
/*
* 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 { analyticsProviderGA } from '../src/plugin';
import { Playground } from './Playground';
createDevApp()
.registerPlugin(analyticsProviderGA)
.addPage({
path: '/ga',
title: 'GA Playground',
element: <Playground />,
})
.render();
@@ -0,0 +1,53 @@
{
"name": "@backstage/plugin-analytics-provider-ga",
"version": "0.1.1",
"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"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.5",
"@backstage/core-components": "^0.1.6",
"@backstage/core-plugin-api": "^0.1.3",
"@backstage/theme": "^0.2.8",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-ga": "^3.3.0",
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.7.4",
"@backstage/core-app-api": "^0.1.4",
"@backstage/dev-utils": "^0.2.2",
"@backstage/test-utils": "^0.1.14",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0"
},
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
@@ -0,0 +1,191 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import ReactGA from 'react-ga';
import { GoogleAnalytics } from './GoogleAnalytics';
describe('GoogleAnalytics', () => {
const trackingId = 'UA-000000-0';
const basicValidConfig = new ConfigReader({
app: { analytics: { ga: { trackingId, testMode: true } } },
});
beforeEach(() => {
ReactGA.testModeAPI.resetCalls();
});
describe('fromConfig', () => {
it('throws when missing trackingId', () => {
const config = new ConfigReader({ app: { analytics: { ga: {} } } });
expect(() => GoogleAnalytics.fromConfig(config)).toThrowError(
/Missing required config value/,
);
});
it('returns implementation', () => {
const api = GoogleAnalytics.fromConfig(basicValidConfig);
expect(api.getDecoratedTracker).toBeDefined();
// Initializes GA with tracking ID.
expect(ReactGA.testModeAPI.calls[0]).toEqual([
'create',
trackingId,
'auto',
]);
});
});
describe('integration', () => {
const domain = {
componentName: 'App',
pluginId: 'some-plugin',
releaseNum: 1337,
};
const advancedConfig = new ConfigReader({
app: {
analytics: {
ga: {
trackingId,
testMode: true,
customDimensionsMetrics: [
{
type: 'dimension',
index: 1,
source: 'domain',
attribute: 'pluginId',
},
{
type: 'dimension',
index: 2,
source: 'context',
attribute: 'extraDimension',
},
{
type: 'metric',
index: 1,
source: 'domain',
attribute: 'releaseNum',
},
{
type: 'metric',
index: 2,
source: 'context',
attribute: 'extraMetric',
},
],
},
},
},
});
it('tracks basic pageview', () => {
const api = GoogleAnalytics.fromConfig(basicValidConfig);
const tracker = api.getDecoratedTracker({ domain });
tracker.captureEvent('navigate', '/');
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/',
});
});
it('tracks basic event', () => {
const api = GoogleAnalytics.fromConfig(basicValidConfig);
const tracker = api.getDecoratedTracker({ domain });
const expectedAction = 'click';
const expectedLabel = 'on something';
const expectedValue = 42;
tracker.captureEvent(expectedAction, expectedLabel, expectedValue);
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'event',
eventCategory: domain.componentName,
eventAction: expectedAction,
eventLabel: expectedLabel,
eventValue: expectedValue,
});
});
it('captures configured custom dimensions/metrics on pageviews', () => {
const api = GoogleAnalytics.fromConfig(advancedConfig);
const tracker = api.getDecoratedTracker({ domain });
tracker.captureEvent('navigate', '/a-page');
// Expect a set command first.
const [setCommand, setData] = ReactGA.testModeAPI.calls[1];
expect(setCommand).toBe('set');
expect(setData).toMatchObject({
dimension1: domain.pluginId,
metric1: domain.releaseNum,
});
// Followed by a send command.
const [sendCommand, sendData] = ReactGA.testModeAPI.calls[2];
expect(sendCommand).toBe('send');
expect(sendData).toMatchObject({
hitType: 'pageview',
page: '/a-page',
});
});
it('captures configured custom dimensions/metrics on events', () => {
const api = GoogleAnalytics.fromConfig(advancedConfig);
const tracker = api.getDecoratedTracker({ domain });
const expectedAction = 'search';
const expectedLabel = 'some query';
const expectedValue = 5;
tracker.captureEvent(expectedAction, expectedLabel, expectedValue, {
extraDimension: false,
extraMetric: 0,
});
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'event',
eventCategory: domain.componentName,
eventAction: expectedAction,
eventLabel: expectedLabel,
eventValue: expectedValue,
dimension1: domain.pluginId,
metric1: domain.releaseNum,
dimension2: false,
metric2: 0,
});
});
it('does not pass non-numeric data on metrics', () => {
const api = GoogleAnalytics.fromConfig(advancedConfig);
const tracker = api.getDecoratedTracker({ domain });
tracker.captureEvent('verb', 'noun', undefined, {
extraMetric: 'not a number',
});
const [, data] = ReactGA.testModeAPI.calls[1];
expect(data).not.toMatchObject({
metric2: 'not a number',
});
});
});
});
@@ -0,0 +1,175 @@
/*
* 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 ReactGA from 'react-ga';
import {
AnalyticsApi,
AnalyticsTracker,
AnalyticsDomainValue,
AnalyticsEventContext,
} from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
type CDM = {
type: 'dimension' | 'metric';
index: number;
source: 'domain' | 'context';
attribute: string;
};
/**
* Type guard for emptiness check in array filter.
*/
function notEmpty<T>(value: T | undefined): value is T {
return value !== undefined;
}
/**
* Google Analytics API provider for the Backstage Analytics API.
*/
export class GoogleAnalytics implements AnalyticsApi {
private readonly cdmConfig: CDM[];
/**
* Instantiate the implementation and initialize ReactGA.
*/
private constructor({
cdmConfig,
trackingId,
testMode,
debug,
}: {
cdmConfig: CDM[];
trackingId: string;
testMode: boolean;
debug: boolean;
}) {
this.cdmConfig = cdmConfig;
// Initialize Google Analytics.
ReactGA.initialize(trackingId, { testMode, debug, titleCase: false });
}
/**
* Instantiate a fully configured GA Analytics API implementation.
*/
static fromConfig(config: Config) {
// Get all necessary configuration.
const trackingId = config.getString('app.analytics.ga.trackingId');
const debug = !!config.getOptionalBoolean('app.analytics.ga.debug');
const testMode = !!config.getOptionalBoolean('app.analytics.ga.testMode');
const cdmConfig =
config
.getOptionalConfigArray('app.analytics.ga.customDimensionsMetrics')
?.map(c => {
return {
type: c.getString('type') as CDM['type'],
index: c.getNumber('index'),
source: c.getString('source') as CDM['source'],
attribute: c.getString('attribute'),
};
}) || [];
// Return an implementation instance.
return new GoogleAnalytics({
trackingId,
cdmConfig,
testMode,
debug,
});
}
/**
* Returns the Google Analytics tracker to API consumers.
*/
getDecoratedTracker({
domain,
}: {
domain: AnalyticsDomainValue;
}): AnalyticsTracker {
return {
captureEvent: (...args) => this.captureEvent(domain, ...args),
};
}
/**
* 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).
*/
private captureEvent(
domain: AnalyticsDomainValue,
verb: string,
noun: string,
value?: number,
context?: AnalyticsEventContext,
) {
const customMetadata = this.getCustomDimensionMetrics(domain, context);
if (verb === 'navigate' && domain?.componentName === 'App') {
// Set any/all custom dimensions.
if (Object.keys(customMetadata).length) {
ReactGA.set(customMetadata);
}
ReactGA.pageview(noun);
return;
}
ReactGA.event({
category: domain.componentName || 'App',
action: verb,
label: noun,
value,
...customMetadata,
});
}
/**
* Returns an object of dimensions/metrics given an Analytics Domain and an
* Event Context, e.g. { dimension1: "some value", metric8: 42 }
*/
private getCustomDimensionMetrics(
domain: AnalyticsDomainValue,
context: AnalyticsEventContext = {},
) {
const dataArray = this.cdmConfig
.map(cdm => {
const value =
cdm.source === 'domain'
? domain[cdm.attribute]
: context[cdm.attribute];
// Never pass a non-numeric value on a metric.
if (cdm.type === 'metric' && typeof value !== 'number') {
return undefined;
}
return value !== undefined
? {
[`${cdm.type}${cdm.index}`]: value,
}
: undefined;
})
.filter(notEmpty);
return dataArray.length
? dataArray.reduce((result, cd) => {
return Object.assign(result, cd);
})
: {};
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { GoogleAnalytics } from './GoogleAnalytics';
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { analyticsProviderGA } from './plugin';
@@ -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 { analyticsProviderGA } from './plugin';
describe('google-analytics', () => {
it('should export plugin', () => {
expect(analyticsProviderGA).toBeDefined();
});
});
@@ -0,0 +1,33 @@
/*
* 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 {
analyticsApiRef,
configApiRef,
createApiFactory,
createPlugin,
} from '@backstage/core-plugin-api';
import { GoogleAnalytics } from './apis/implementations/AnalyticsApi';
export const analyticsProviderGA = createPlugin({
id: 'analytics-provider-ga',
apis: [
createApiFactory({
api: analyticsApiRef,
deps: { config: configApiRef },
factory: ({ config }) => GoogleAnalytics.fromConfig(config),
}),
],
});
@@ -0,0 +1,17 @@
/*
* 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';
+6 -1
View File
@@ -23031,11 +23031,16 @@ react-fast-compare@^2.0.4:
resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==
react-fast-compare@^3.0.1, react-fast-compare@^3.1.1, react-fast-compare@^3.2.0:
react-fast-compare@^3.0.1, react-fast-compare@^3.1.1:
version "3.2.0"
resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
react-ga@^3.3.0:
version "3.3.0"
resolved "https://registry.npmjs.org/react-ga/-/react-ga-3.3.0.tgz#c91f407198adcb3b49e2bc5c12b3fe460039b3ca"
integrity sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ==
react-helmet-async@^1.0.7:
version "1.0.9"
resolved "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.0.9.tgz#5b9ed2059de6b4aab47f769532f9fbcbce16c5ca"