diff --git a/.changeset/selfish-lizards-invent.md b/.changeset/selfish-lizards-invent.md new file mode 100644 index 0000000000..fd486416d8 --- /dev/null +++ b/.changeset/selfish-lizards-invent.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-analytics-module-ga': patch +--- + +Added support for sending virtual pageviews on `search` events in order to enable +Site Search functionality in GA. For more information consult [README](/plugins/analytics-module-ga/README.md#enabling-site-search) diff --git a/plugins/analytics-module-ga/README.md b/plugins/analytics-module-ga/README.md index 4b822ea3e3..c2d79aa45d 100644 --- a/plugins/analytics-module-ga/README.md +++ b/plugins/analytics-module-ga/README.md @@ -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 diff --git a/plugins/analytics-module-ga/config.d.ts b/plugins/analytics-module-ga/config.d.ts index b91f5812c1..188727bf93 100644 --- a/plugins/analytics-module-ga/config.d.ts +++ b/plugins/analytics-module-ga/config.d.ts @@ -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. diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts index c9c2a02c69..65e9256aa8 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -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); diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index 498b118d0d..17c5b26be1 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -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; 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, diff --git a/plugins/analytics-module-ga/src/util/VirtualSearchPageView.ts b/plugins/analytics-module-ga/src/util/VirtualSearchPageView.ts new file mode 100644 index 0000000000..bb3d40afd3 --- /dev/null +++ b/plugins/analytics-module-ga/src/util/VirtualSearchPageView.ts @@ -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'), + }; +}