Review feedback.

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2021-09-05 21:19:33 +02:00
parent c582819a01
commit edd5293d68
18 changed files with 78 additions and 56 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+132
View File
@@ -0,0 +1,132 @@
# Analytics Module: 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 app:
`cd packages/app && yarn add @backstage/plugin-analytics-module-ga`
2. Register the plugin with your App. In most instances of Backstage, this is
as simple as adding `export { analyticsModuleGA } from '@backstage/plugin-analytics-module-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
+14
View File
@@ -0,0 +1,14 @@
## API Report File for "@backstage/plugin-analytics-module-ga"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "analyticsModuleGA" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const analyticsModuleGA: BackstagePlugin<{}, {}>;
// (No @packageDocumentation comment for this package)
```
+89
View File
@@ -0,0 +1,89 @@
/*
* 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?: {
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, {});
+28
View File
@@ -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 { analyticsModuleGA } from '../src/plugin';
import { Playground } from './Playground';
createDevApp()
.registerPlugin(analyticsModuleGA)
.addPage({
path: '/ga',
title: 'GA Playground',
element: <Playground />,
})
.render();
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@backstage/plugin-analytics-module-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.2.0",
"@backstage/core-plugin-api": "^0.1.4",
"@backstage/theme": "^0.2.9",
"@material-ui/core": "^4.12.2",
"@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.6",
"@backstage/core-app-api": "^0.1.6",
"@backstage/dev-utils": "^0.2.4",
"@backstage/test-utils": "^0.1.16",
"@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';
+16
View File
@@ -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 { analyticsModuleGA } 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 { analyticsModuleGA } from './plugin';
describe('google-analytics', () => {
it('should export plugin', () => {
expect(analyticsModuleGA).toBeDefined();
});
});
+33
View File
@@ -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 analyticsModuleGA = 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';