Review feedback.
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
+191
@@ -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 { 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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user