Use standard event names for page_view

Remove fnSend and send all captured events through ga4-events
Change the version to minor
Fix package.json
Changes to test to make it pass

Signed-off-by: sriram ramakrishnan <sramakr@gmail.com>
This commit is contained in:
sriram ramakrishnan
2023-03-29 19:27:36 -04:00
parent 22b46f7f56
commit db64baf57b
13 changed files with 262 additions and 252 deletions
@@ -1,3 +1,18 @@
/*
* 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';
@@ -19,12 +34,6 @@ fnSet.mockImplementation((fieldObject: any) => {
return;
});
const fnSend = jest.spyOn(ReactGA, 'send');
// @ts-ignore
fnSend.mockImplementation((fieldObject: any) => {
return;
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -38,7 +47,9 @@ describe('GoogleAnalytics4', () => {
};
const measurementId = 'G-000000-0';
const basicValidConfig = new ConfigReader({
app: { analytics: { ga4: { measurementId: measurementId, testMode: true } } },
app: {
analytics: { ga4: { measurementId: measurementId, testMode: true } },
},
});
describe('fromConfig', () => {
@@ -59,9 +70,10 @@ describe('GoogleAnalytics4', () => {
subject: '/',
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/',
expect(fnEvent).toHaveBeenCalledWith('page_view', {
action: 'page_view',
label: '/',
category: 'App',
});
});
});
@@ -107,6 +119,19 @@ describe('GoogleAnalytics4', () => {
},
});
const allowAllContextsAndAttrsConfig = new ConfigReader({
app: {
analytics: {
ga4: {
measurementId: measurementId,
testMode: true,
allowedContexts: ['*'],
allowedAttributes: ['*'],
},
},
},
});
it('testing content grouping', () => {
const api = GoogleAnalytics4.fromConfig(configWithContentGrouping);
api.captureEvent({
@@ -115,9 +140,11 @@ describe('GoogleAnalytics4', () => {
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/a-page',
expect(fnEvent).toHaveBeenCalledWith('page_view', {
action: 'page_view',
label: '/a-page',
category: 'App',
value: undefined,
content_group: context.pluginId,
});
});
@@ -133,12 +160,11 @@ describe('GoogleAnalytics4', () => {
value: expectedValue,
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'event',
eventAction: 'search',
eventCategory: 'App',
eventLabel: 'search-term',
eventValue: 42,
expect(fnEvent).toHaveBeenCalledWith('search', {
action: 'search',
category: 'App',
label: 'search-term',
value: 42,
search_term: 'search-term',
});
});
@@ -156,12 +182,11 @@ describe('GoogleAnalytics4', () => {
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'event',
eventCategory: context.extension,
eventAction: expectedAction,
eventLabel: expectedLabel,
eventValue: expectedValue,
expect(fnEvent).toHaveBeenCalledWith('click', {
action: 'click',
category: context.extension,
label: 'on something',
value: expectedValue,
});
});
@@ -173,14 +198,41 @@ describe('GoogleAnalytics4', () => {
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/a-page',
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);
@@ -198,12 +250,11 @@ describe('GoogleAnalytics4', () => {
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'event',
eventCategory: context.extension,
eventAction: expectedAction,
eventLabel: expectedLabel,
eventValue: expectedValue,
expect(fnEvent).toHaveBeenCalledWith('search', {
action: 'search',
category: context.extension,
label: expectedLabel,
value: expectedValue,
c_pluginId: context.pluginId,
c_releaseNum: context.releaseNum,
search_term: expectedLabel,
@@ -223,9 +274,9 @@ describe('GoogleAnalytics4', () => {
});
expect(fnEvent).not.toHaveBeenCalledWith({
eventCategory: context.extension,
eventAction: 'verb',
eventLabel: 'noun',
category: context.extension,
action: 'verb',
label: 'noun',
c_pluginId: context.pluginId,
c_releaseNum: context.releaseNum,
c_extraMetric: 'not a number',
@@ -262,7 +313,11 @@ describe('GoogleAnalytics4', () => {
const optionalConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'optional' },
ga4: {
measurementId: measurementId,
testMode: true,
identity: 'optional',
},
},
},
});
@@ -288,7 +343,11 @@ describe('GoogleAnalytics4', () => {
const optionalConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'optional' },
ga4: {
measurementId: measurementId,
testMode: true,
identity: 'optional',
},
},
},
});
@@ -317,7 +376,11 @@ describe('GoogleAnalytics4', () => {
const disabledConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'disabled' },
ga4: {
measurementId: measurementId,
testMode: true,
identity: 'disabled',
},
},
},
});
@@ -332,9 +395,11 @@ describe('GoogleAnalytics4', () => {
await new Promise(resolve => setTimeout(resolve));
// A pageview should have been fired immediately.
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/',
expect(fnEvent).toHaveBeenCalledWith('page_view', {
action: 'page_view',
label: '/',
category: 'App',
value: undefined,
});
// There should not have been a UserID set.
@@ -346,7 +411,11 @@ describe('GoogleAnalytics4', () => {
const requiredConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'required' },
ga4: {
measurementId: measurementId,
testMode: true,
identity: 'required',
},
},
},
});
@@ -359,7 +428,11 @@ describe('GoogleAnalytics4', () => {
const requiredConfig = new ConfigReader({
app: {
analytics: {
ga4: { measurementId: measurementId, testMode: true, identity: 'required' },
ga4: {
measurementId: measurementId,
testMode: true,
identity: 'required',
},
},
},
});
@@ -387,20 +460,21 @@ describe('GoogleAnalytics4', () => {
});
// Then a pageview should have been fired with a queue time.
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/',
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(fnSend).toHaveBeenCalledWith({
hitType: 'event',
expect(fnEvent).toHaveBeenCalledWith('test', {
action: 'test',
timestamp_micros: expect.any(Number),
eventAction: 'test',
eventCategory: 'App',
eventLabel: 'some label',
eventValue: undefined,
label: 'some label',
category: 'App',
value: undefined,
});
// And subsequent hits should not have a queue time.
@@ -410,9 +484,11 @@ describe('GoogleAnalytics4', () => {
context,
});
expect(fnSend).toHaveBeenCalledWith({
hitType: 'pageview',
page: '/page-2',
expect(fnEvent).toHaveBeenCalledWith('page_view', {
action: 'page_view',
label: '/page-2',
category: 'App',
value: undefined,
});
});
});
@@ -1,3 +1,18 @@
/*
* 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,
@@ -7,7 +22,7 @@ import {
IdentityApi,
} from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
import { DeferredCapture } from '../../../util';
import { DeferredCapture } from '../../../util/DeferredCapture';
/**
* Google Analytics API provider for the Backstage Analytics API.
@@ -21,7 +36,6 @@ export class GoogleAnalytics4 implements AnalyticsApi {
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
@@ -60,9 +74,8 @@ export class GoogleAnalytics4 implements AnalyticsApi {
});
this.contentGroupBy = contentGroupBy;
this.allowedContexts = allowedContexts;
this.allowedAttributes = allowedAttributes;
this.allowedContexts = allowedContexts;
// If identity is required, defer event capture until identity is known.
this.capture = new DeferredCapture({ defer: identity === 'required' });
@@ -82,8 +95,8 @@ export class GoogleAnalytics4 implements AnalyticsApi {
/**
* Instantiate a fully configured GA Analytics API implementation.
* @param config - Config object from app config
* @param options - options with identityApi and userIdTransform config
* @param config Config object from app config
* @param options options with identityApi and userIdTransform config
*/
static fromConfig(
config: Config,
@@ -135,7 +148,7 @@ export class GoogleAnalytics4 implements AnalyticsApi {
* 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
* @param event AnalyticsEvent type captured
*/
captureEvent(event: AnalyticsEvent) {
const { context, action, subject, value, attributes } = event;
@@ -145,7 +158,15 @@ export class GoogleAnalytics4 implements AnalyticsApi {
}
if (action === 'navigate' && context.extension === 'App') {
this.capture.pageview(subject, customEventData);
this.capture.event(
{
category: context.extension || 'App',
action: 'page_view',
label: subject,
value,
},
customEventData,
);
return;
}
@@ -178,17 +199,27 @@ export class GoogleAnalytics4 implements AnalyticsApi {
[x: string]: string | number | boolean | undefined;
} = {};
this.allowedContexts?.forEach(ctx => {
const contextKeys =
this.allowedContexts?.join('') === '*'
? Object.keys(context)
: this.allowedContexts;
contextKeys?.forEach(ctx => {
if (context[ctx]) {
customEventParameters[`c_${ctx}`] = context[ctx];
}
});
this.allowedAttributes?.forEach(attr => {
const attrKeys =
this.allowedAttributes?.join('') === '*'
? Object.keys(attributes)
: this.allowedAttributes;
attrKeys?.forEach(attr => {
if (attributes[attr]) {
customEventParameters[`a_${attr}`] = attributes[attr];
}
});
return customEventParameters;
}
@@ -1 +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';
+15 -1
View File
@@ -1,2 +1,16 @@
export { analyticsModuleGA4 } from './plugin';
/*
* 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';
@@ -1,22 +0,0 @@
/*
* 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 { analyticsModuleGA4 } from './plugin';
describe('google-analytics', () => {
it('should export plugin', () => {
expect(analyticsModuleGA4).toBeDefined();
});
});
@@ -1,26 +0,0 @@
/*
* 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 { 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',
});
@@ -1,16 +1,29 @@
/*
* 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: {
hitType: 'pageview' | 'event';
[x: string]: any;
};
};
const PageViewEvent = 'pageview';
/**
* A wrapper around ReactGA that can optionally handle latent capture logic.
*
@@ -46,32 +59,6 @@ export class DeferredCapture {
}
}
/**
* Either forwards the pageview directly to GA, or (if configured) enqueues
* the pageview hit to be captured when ready.
* @param path pageview path
* @param metadata any object that can be passed as additional parameter to the event
*/
pageview(path: string, metadata: any = {}) {
if (this.queue) {
this.queue.push({
data: {
hitType: PageViewEvent,
timestamp_micros: Date.now() * 1000,
page: path,
...metadata,
},
});
return;
}
ReactGA.send({
hitType: PageViewEvent,
page: path,
...metadata,
});
}
/**
* Either forwards the event directly to GA, or (if configured) enqueues the
* event hit to be captured when ready.
@@ -80,15 +67,12 @@ export class DeferredCapture {
*/
event(eventDetails: UaEventOptions, metadata: any = {}) {
const data = {
hitType: 'event',
eventCategory: eventDetails.category,
eventLabel: eventDetails.label!,
eventAction: eventDetails.action,
eventValue: eventDetails.value,
...eventDetails,
...metadata,
};
if (this.queue) {
this.queue.push({
hitType: eventDetails.action,
data: {
...data,
timestamp_micros: Date.now() * 1000,
@@ -96,7 +80,7 @@ export class DeferredCapture {
});
return;
}
ReactGA.send(data);
ReactGA.event(eventDetails.action, data);
}
/**
@@ -105,7 +89,7 @@ export class DeferredCapture {
*/
private sendDeferred(hit: Hit) {
// Send the hit with the appropriate queue time (`qt`).
ReactGA.send({
ReactGA.event(hit.hitType, {
...hit.data,
});
}
@@ -1 +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';