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
@@ -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';