Merge pull request #14901 from graynk/ga_virtual_pageview

This commit is contained in:
Eric Peterson
2022-12-01 18:38:11 +01:00
committed by GitHub
6 changed files with 236 additions and 1 deletions
+27
View File
@@ -169,6 +169,33 @@ export const apis: AnyApiFactory[] = [
];
```
### Enabling Site Search
If you wish to see all of the search events in the [Site Search](https://support.google.com/analytics/answer/1012264)
section of Google Analytics, you can enable sending virtual pageviews on every `search` event like so:
```yaml
app:
analytics:
ga:
virtualSearchPageView:
mode: only # Defaults to 'disabled'
mountPath: /virtual-search # Defaults to '/search'
searchQuery: term # Defaults to 'query'
categoryQuery: sc # Omitted by default
```
Available `mode`s are:
- `disabled` - no virtual pageviews are sent, default behavior
- `only` - sends virtual pageviews _instead_ of `search` events
- `both` - sends both virtual pageviews _and_ `search` events
Virtual pageviews will be sent to the path specified in the `mountPath`, the search term will be
set as the value for query parameter `searchQuery` and category (if provided) will be set as the value for
query parameter `categoryQuery`, e.g. the example config above will result in
virtual pageviews being sent to `/virtual-search?term=SearchTermHere&sc=CategoryHere`.
### Debugging and Testing
In pre-production environments, you may wish to set additional configurations
+32
View File
@@ -53,6 +53,38 @@ export interface Config {
*/
identity?: 'disabled' | 'optional' | 'required';
/**
* Controls whether to send virtual pageviews on `search` events.
* Can be used to enable Site Search in GA.
*/
virtualSearchPageView?: {
/**
* - `disabled`: (Default) no virtual pageviews are sent
* - `only`: Sends virtual pageview _instead_ of the `search` event
* - `both`: Sends both the `search` event _and_ the virtual pageview
* @visibility frontend
*/
mode?: 'disabled' | 'only' | 'both';
/**
* Specifies on which path the main Search page is mounted.
* Defaults to `/search`.
* @visibility frontend
*/
mountPath?: string;
/**
* Specifies which query param is used for the term query in the virtual pageview URL.
* Defaults to `query`.
* @visibility frontend
*/
searchQuery?: string;
/**
* Specifies which query param is used for the category query in the virtual pageview URL.
* Skipped by default.
* @visibility frontend
*/
categoryQuery?: string;
};
/**
* Whether or not to log analytics debug statements to the console.
* Defaults to false.
@@ -25,6 +25,7 @@ describe('GoogleAnalytics', () => {
pluginId: 'some-plugin',
routeRef: 'unknown',
releaseNum: 1337,
searchTypes: 'test category',
};
const trackingId = 'UA-000000-0';
const basicValidConfig = new ConfigReader({
@@ -152,6 +153,101 @@ describe('GoogleAnalytics', () => {
});
});
it('captures virtual pageviews instead of search events', () => {
const config = new ConfigReader({
app: {
analytics: {
ga: {
trackingId,
testMode: true,
virtualSearchPageView: { mode: 'only' },
},
},
},
});
const api = GoogleAnalytics.fromConfig(config);
api.captureEvent({
action: 'search',
subject: 'test search',
context,
});
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/search?query=test+search',
});
expect(ReactGA.testModeAPI.calls).toHaveLength(2);
});
it('captures virtual pageviews alongside search events', () => {
const config = new ConfigReader({
app: {
analytics: {
ga: {
trackingId,
testMode: true,
virtualSearchPageView: { mode: 'both' },
},
},
},
});
const api = GoogleAnalytics.fromConfig(config);
api.captureEvent({
action: 'search',
subject: 'test search',
context,
});
const [pageviewCommand, pageViewData] = ReactGA.testModeAPI.calls[1];
expect(pageviewCommand).toBe('send');
expect(pageViewData).toMatchObject({
hitType: 'pageview',
page: '/search?query=test+search',
});
const [searchCommand, searchData] = ReactGA.testModeAPI.calls[2];
expect(searchCommand).toBe('send');
expect(searchData).toMatchObject({
hitType: 'event',
eventCategory: context.extension,
eventAction: 'search',
eventLabel: 'test search',
});
});
it('captures virtual pageviews on custom route with custom search query and custom category', () => {
const config = new ConfigReader({
app: {
analytics: {
ga: {
trackingId,
testMode: true,
virtualSearchPageView: {
mode: 'only',
mountPath: '/custom',
searchQuery: 'term',
categoryQuery: 'sc',
},
},
},
},
});
const api = GoogleAnalytics.fromConfig(config);
api.captureEvent({
action: 'search',
subject: 'test search',
context,
});
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/custom?term=test+search&sc=test+category',
});
});
it('captures configured custom dimensions/metrics on events', () => {
const api = GoogleAnalytics.fromConfig(advancedConfig);
@@ -18,12 +18,16 @@ import ReactGA from 'react-ga';
import {
AnalyticsApi,
AnalyticsContextValue,
AnalyticsEventAttributes,
AnalyticsEvent,
AnalyticsEventAttributes,
IdentityApi,
} from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
import { DeferredCapture } from '../../../util';
import {
parseVirtualSearchPageViewConfig,
VirtualSearchPageViewConfig,
} from '../../../util/VirtualSearchPageView';
type CustomDimensionOrMetricConfig = {
type: 'dimension' | 'metric';
@@ -40,6 +44,7 @@ export class GoogleAnalytics implements AnalyticsApi {
private readonly cdmConfig: CustomDimensionOrMetricConfig[];
private customUserIdTransform?: (userEntityRef: string) => Promise<string>;
private readonly capture: DeferredCapture;
private readonly virtualSearchPageView: VirtualSearchPageViewConfig;
/**
* Instantiate the implementation and initialize ReactGA.
@@ -51,6 +56,7 @@ export class GoogleAnalytics implements AnalyticsApi {
identity: string;
trackingId: string;
scriptSrc?: string;
virtualSearchPageView: VirtualSearchPageViewConfig;
testMode: boolean;
debug: boolean;
}) {
@@ -61,11 +67,13 @@ export class GoogleAnalytics implements AnalyticsApi {
identityApi,
userIdTransform = 'sha-256',
scriptSrc,
virtualSearchPageView,
testMode,
debug,
} = options;
this.cdmConfig = cdmConfig;
this.virtualSearchPageView = virtualSearchPageView;
// Initialize Google Analytics.
ReactGA.initialize(trackingId, {
@@ -105,6 +113,9 @@ export class GoogleAnalytics implements AnalyticsApi {
const scriptSrc = config.getOptionalString('app.analytics.ga.scriptSrc');
const identity =
config.getOptionalString('app.analytics.ga.identity') || 'disabled';
const virtualSearchPageView = parseVirtualSearchPageViewConfig(
config.getOptionalConfig('app.analytics.ga.virtualSearchPageView'),
);
const debug = config.getOptionalBoolean('app.analytics.ga.debug') ?? false;
const testMode =
config.getOptionalBoolean('app.analytics.ga.testMode') ?? false;
@@ -134,6 +145,7 @@ export class GoogleAnalytics implements AnalyticsApi {
identity,
trackingId,
scriptSrc,
virtualSearchPageView,
cdmConfig,
testMode,
debug,
@@ -154,6 +166,23 @@ export class GoogleAnalytics implements AnalyticsApi {
return;
}
if (this.virtualSearchPageView.mode !== 'disabled' && action === 'search') {
const { mountPath, searchQuery, categoryQuery } =
this.virtualSearchPageView;
const params = new URLSearchParams();
params.set(searchQuery, subject);
if (categoryQuery) {
params.set(categoryQuery, context.searchTypes?.toString() ?? '');
}
this.capture.pageview(
`${mountPath}?${params.toString()}`,
customMetadata,
);
if (this.virtualSearchPageView.mode === 'only') {
return;
}
}
this.capture.event({
category: context.extension || 'App',
action,
@@ -0,0 +1,45 @@
/*
* Copyright 2022 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 { Config } from '@backstage/config';
type VirtualSearchPageViewType = 'disabled' | 'only' | 'both';
export type VirtualSearchPageViewConfig = {
mode: VirtualSearchPageViewType;
mountPath: string;
searchQuery: string;
categoryQuery?: string;
};
function isVirtualSearchPageViewType(
value: string | undefined,
): value is VirtualSearchPageViewType {
return value === 'disabled' || value === 'only' || value === 'both';
}
export function parseVirtualSearchPageViewConfig(
config: Config | undefined,
): VirtualSearchPageViewConfig {
const vspvModeString = config?.getOptionalString('mode');
return {
mode: isVirtualSearchPageViewType(vspvModeString)
? vspvModeString
: 'disabled',
mountPath: config?.getOptionalString('mountPath') ?? '/search',
searchQuery: config?.getOptionalString('searchQuery') ?? 'query',
categoryQuery: config?.getOptionalString('categoryQuery'),
};
}