diff --git a/.changeset/mighty-cows-greet.md b/.changeset/mighty-cows-greet.md new file mode 100644 index 0000000000..2e871db0c6 --- /dev/null +++ b/.changeset/mighty-cows-greet.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-analytics-module-ga4': minor +--- + +Plugin provides Backstage Analytics API for Google Analytics 4. Once installed and configured, analytics events will be sent to GA4 as your users navigate and use your Backstage instance diff --git a/plugins/analytics-module-ga4/.eslintrc.js b/plugins/analytics-module-ga4/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/analytics-module-ga4/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/analytics-module-ga4/CHANGELOG.md b/plugins/analytics-module-ga4/CHANGELOG.md new file mode 100644 index 0000000000..041ec1cf63 --- /dev/null +++ b/plugins/analytics-module-ga4/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/plugin-analytics-module-ga4 diff --git a/plugins/analytics-module-ga4/README.md b/plugins/analytics-module-ga4/README.md new file mode 100644 index 0000000000..d060c9d9b1 --- /dev/null +++ b/plugins/analytics-module-ga4/README.md @@ -0,0 +1,221 @@ +# Analytics Module: Google Analytics 4 + +This plugin provides an opinionated implementation of the Backstage Analytics +API for Google Analytics 4. Once installed and configured, analytics events will +be sent to GA as your users navigate and use your Backstage instance. + +This plugin contains no other functionality. + +## Installation + +1. Install the plugin package in your Backstage app: + `cd packages/app && yarn add @backstage/plugin-analytics-module-ga4` +2. Wire up the API implementation to your App: + +```tsx +// packages/app/src/apis.ts +import { + analyticsApiRef, + configApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { GoogleAnalytics4 } from '@backstage/plugin-analytics-module-ga4'; + +export const apis: AnyApiFactory[] = [ + // Instantiate and register the GA Analytics API Implementation. + createApiFactory({ + api: analyticsApiRef, + deps: { configApi: configApiRef, identityApi: identityApiRef }, + factory: ({ configApi, identityApi }) => + GoogleAnalytics4.fromConfig(configApi, { + identityApi, + }), + }), +]; +``` + +3. Configure the plugin in your `app-config.yaml`: + +The following is the minimum configuration required to start sending analytics +events to GA. All that's needed is your GA4 measurement ID: + +```yaml +# app-config.yaml +app: + analytics: + ga4: + measurementId: G-0000000-0 +``` + +4. Update CSP in your `app-config.yaml`: + +The following is the minimal content security policy required to load scripts from GA. + +```yaml +backend: + csp: + connect-src: ["'self'", 'http:', 'https:'] + # Add these two lines below + script-src: ["'self'", "'unsafe-eval'", 'https://www.google-analytics.com'] + img-src: ["'self'", 'data:', 'https://www.google-analytics.com'] +``` + +## Configuration + +In order to be able to analyze usage of your Backstage instance by plugin, we recommend configuring [a content grouping](#enabling-content-grouping). +Additional dimensional data can be captured using custom dimensions, like this: + +1. First, [configure the custom dimension in GA] [configure-custom-dimension]. + Be sure to set the Scope to `Event`, and name it `dimension1`. +2. Then, add a mapping to your `app.analytics.ga4` configuration that instructs + the plugin to capture Plugin IDs on the custom dimension you just created. + It should look like this: +3. `allowedContexts` config accepts array of string, where each entry is a context parameter that will be sent in the event. + context names will be prefixed by `c_`. +4. `allowedAttributes` config accepts array of string, where each entry is an attribute that will be sent in the event. + attribute names will be prefixed by `a_`. +5. `allowedContexts` and `allowedAttributes` are optional, if not provided, no additional context and attributes will be sent. +6. if `allowedContexts` or `allowedAttributes` is set to '\*', all context and attributes will be sent. + +```yaml +app: + analytics: + ga4: + measurementId: G-0000000-0 + allowedContexts: ['pluginId'] +``` + +```yaml +app: + analytics: + ga4: + allowedContexts: ['pluginId'] + allowedAttributes: ['someEventContextAttr'] +``` + +### User IDs + +This plugin supports accurately deriving user-oriented metrics (like monthly +active users) using Google Analytics' [user ID views][ga-user-id-view]. To +enable this... + +1. Be sure you've gone through the process of setting up a user ID view in your + Backstage instance's Google Analytics property (see docs linked above). +2. Make sure you instantiate `GoogleAnalytics` with an `identityApi` instance + passed to it, as shown in the installation section above. +3. Set `app.analytics.ga4.identity` to either `required` or `optional` in your + `app.config.yaml`, like this: + + ```yaml + app: + analytics: + ga4: + measurementId: G-0000000-0 + identity: optional + ``` + + Set `identity` to `optional` if you need accurate session counts, including + cases where users do not sign in at all. Use `required` if you need all hits + to be associated with a user ID without exception (and don't mind if some + sessions are not captured, such as those where no sign in occur). + +Note that, to comply with GA policies, the value of the User ID is +pseudonymized before being sent to GA. By default, it is a `sha256` hash of the +current user's `userEntityRef` as returned by the `identityApi`. To set a +different value, provide a `userIdTransform` function alongside `identityApi` +when you instantiate `GoogleAnalytics`. This function will be passed the +`userEntityRef` as an argument and should resolve to the value you wish to set +as the user ID. For example: + +```typescript +import { + analyticsApiRef, + configApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga'; + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: analyticsApiRef, + deps: { configApi: configApiRef, identityApi: identityApiRef }, + factory: ({ configApi, identityApi }) => + GoogleAnalytics4.fromConfig(configApi, { + identityApi, + userIdTransform: async (userEntityRef: string): Promise => { + return customHashingFunction(userEntityRef); + }, + }), + }), +]; +``` + +### Enabling content grouping + +Content groups enable you to categorize pages and screens into custom buckets which you can see +metrics for related groups of information. +More about content grouping here [content groups][content-grouping]. +It's recommended to enable content grouping by PluginId. `contentGrouping` supports `routeRef` and extension. + +```yaml +app: + analytics: + ga4: + contentGrouping: pluginId +``` + +Please note, content grouping takes 24hrs to show up in the Google Analytics dashboard. + +### Debugging and Testing + +In pre-production environments, you may wish to set additional configurations +to turn off reporting to Analytics and/or print debug statements to the +console. You can do so like this: + +```yaml +app: + analytics: + ga4: + testMode: true # Prevents data being sent to GA + debug: true # Logs analytics event to the web console +``` + +You might commonly set the above in an `app-config.local.yaml` file, which is +normally `gitignore`'d but loaded and merged in when Backstage is bootstrapped. + +## Development + +If you would like to contribute improvements to this plugin, the easiest way to +make and test changes is to do the following: + +1. Clone the main Backstage monorepo `git clone git@github.com:backstage/backstage.git` +2. Install all dependencies `yarn install` +3. If one does not exist, create an `app-config.local.yaml` file in the root of + the monorepo and add config for this plugin (see below) +4. Enter this plugin's working directory: `cd plugins/analytics-provider-ga4` +5. Start the plugin in isolation: `yarn start` +6. Navigate to the playground page at `http://localhost:3000/ga4` +7. Open the web console to see events fire when you navigate or when you + interact with instrumented components. + +Code for the isolated version of the plugin can be found inside the [/dev](./dev) +directory. Changes to the plugin are hot-reloaded. + +#### Recommended Dev Config + +Paste this into your `app-config.local.yaml` while developing this plugin: + +```yaml +app: + analytics: + ga4: + measurementId: G-0000000-0 + debug: true + testMode: true + allowedContexts: ['pluginId'] +``` + +[what-is-a-custom-dimension]: https://support.google.com/analytics/answer/2709828 +[configure-custom-dimension]: https://support.google.com/analytics/answer/10075209?hl=en# +[ga-user-id-view]: https://support.google.com/analytics/answer/3123669 +[content-grouping]: https://support.google.com/analytics/answer/11523339?hl=en diff --git a/plugins/analytics-module-ga4/api-report.md b/plugins/analytics-module-ga4/api-report.md new file mode 100644 index 0000000000..77f3094210 --- /dev/null +++ b/plugins/analytics-module-ga4/api-report.md @@ -0,0 +1,26 @@ +## API Report File for "@backstage/plugin-analytics-module-ga4" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { Config } from '@backstage/config'; +import { IdentityApi } from '@backstage/core-plugin-api'; + +// @public +export class GoogleAnalytics4 implements AnalyticsApi { + captureEvent(event: AnalyticsEvent): void; + static fromConfig( + config: Config, + options?: { + identityApi?: IdentityApi; + userIdTransform?: + | 'sha-256' + | ((userEntityRef: string) => Promise); + }, + ): GoogleAnalytics4; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/analytics-module-ga4/config.d.ts b/plugins/analytics-module-ga4/config.d.ts new file mode 100644 index 0000000000..d90ccb5938 --- /dev/null +++ b/plugins/analytics-module-ga4/config.d.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2020 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 interface Config { + app: { + // TODO: Only marked as optional because backstage-cli config:check in the + // context of the monorepo is too strict. Ideally, this would be marked as + // required. + analytics?: { + ga4: { + /** + * Google Analytics measurement ID, e.g. G-000000-0 + * @visibility frontend + */ + measurementId: string; + + /** + * Controls how the identityApi is used when sending data to GA: + * + * - `disabled`: (Default) Explicitly prevents a user's identity from + * being used when capturing events in GA. + * - `optional`: Pageviews and hits are forwarded to GA as they happen + * and only include user identity metadata once known. Guarantees + * that hits are captured for all sessions, even if no sign in + * occurs, but may result in dropped hits in User ID views. + * - `required`: All pageviews and hits are deferred until an identity + * is known. Guarantees that all data sent to GA correlates to a user + * identity, but prevents GA from receiving events for sessions in + * which a user does not sign in. An `identityApi` instance must be + * passed during instantiation when set to this value. + * + * @visibility frontend + */ + identity?: 'disabled' | 'optional' | 'required'; + + /** + * Whether to log analytics debug statements to the console. + * Defaults to false. + * + * @visibility frontend + */ + debug?: boolean; + + /** + * Prevents events from actually being sent when set to true. Defaults + * to false. + * + * @visibility frontend + */ + testMode?: boolean; + + /** + * Content grouping definition + * Feature available in Google Analytics 4 + * More information https://support.google.com/analytics/answer/11523339?hl=en + * Data can be grouped by pluginId, routeRef + * Takes 24 hours before metrics shows up in GA dashboard + * Specifies the dimension to be used for content grouping + * Can be one of pluginId, extension or routeRef + * @visibility frontend + * + */ + contentGrouping?: 'pluginId' | 'extension' | 'routeRef'; + + /** + * Configuration informing how Analytics Context and Event Attributes + * metadata will be captured in Google Analytics. + * Contexts that will be sent as parameters in the event. + * context-name will be prefixed by c_, for example, pluginId will be c_pluginId in the event. + * + */ + allowedContexts?: string[] | ['*']; + /** + * + * Attributes that will be sent as parameters in the event + * attribute-name will be prefixed by a_, for example , testAttribute will be c_testAttribute in the event. + * + */ + allowedAttributes?: string[] | ['*']; + }; + }; + }; +} diff --git a/plugins/analytics-module-ga4/dev/Playground.tsx b/plugins/analytics-module-ga4/dev/Playground.tsx new file mode 100644 index 0000000000..cf21aadacc --- /dev/null +++ b/plugins/analytics-module-ga4/dev/Playground.tsx @@ -0,0 +1,26 @@ +/* + * 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 React from 'react'; +import { Link } from '@backstage/core-components'; + +export const Playground = () => { + return ( + <> + Click Here + + ); +}; diff --git a/plugins/analytics-module-ga4/dev/index.tsx b/plugins/analytics-module-ga4/dev/index.tsx new file mode 100644 index 0000000000..eb694498f5 --- /dev/null +++ b/plugins/analytics-module-ga4/dev/index.tsx @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { Playground } from './Playground'; + +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', +}); +createDevApp() + .registerPlugin(analyticsModuleGA4) + .addPage({ + path: '/ga4', + title: 'GA4 Playground', + element: , + }) + .render(); diff --git a/plugins/analytics-module-ga4/package.json b/plugins/analytics-module-ga4/package.json new file mode 100644 index 0000000000..ea2ed16888 --- /dev/null +++ b/plugins/analytics-module-ga4/package.json @@ -0,0 +1,57 @@ +{ + "name": "@backstage/plugin-analytics-module-ga4", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin-module" + }, + "scripts": { + "build": "backstage-cli package build", + "start": "backstage-cli package start", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "clean": "backstage-cli package clean" + }, + "dependencies": { + "@backstage/config": "workspace:^", + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/theme": "workspace:^", + "react-ga4": "^2.0.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0", + "react-dom": "^16.13.1 || ^17.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/dom": "^8.0.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/jest": "^28.1.3", + "@types/node": "^16.11.26", + "@types/react": "^16.13.1 || ^17.0.0", + "cross-fetch": "^3.1.5", + "msw": "^1.0.0" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts new file mode 100644 index 0000000000..16db7075b3 --- /dev/null +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts @@ -0,0 +1,495 @@ +/* + * 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'; +import { GoogleAnalytics4 } from './GoogleAnalytics4'; +import { UaEventOptions } from 'react-ga4/types/ga4'; + +const fnEvent = jest.spyOn(ReactGA, 'event'); + +fnEvent.mockImplementation( + // @ts-ignore + (optionsOrName: string | UaEventOptions, params?: any) => { + return; + }, +); + +const fnSet = jest.spyOn(ReactGA, 'set'); +// @ts-ignore +fnSet.mockImplementation((fieldObject: any) => { + return; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('GoogleAnalytics4', () => { + const context = { + extension: 'App', + pluginId: 'some-plugin', + routeRef: 'unknown', + releaseNum: 1337, + }; + const measurementId = 'G-000000-0'; + const basicValidConfig = new ConfigReader({ + app: { + analytics: { ga4: { measurementId: measurementId, testMode: true } }, + }, + }); + + describe('fromConfig', () => { + it('throws when missing measurementId', () => { + const config = new ConfigReader({ app: { analytics: { ga4: {} } } }); + expect(() => GoogleAnalytics4.fromConfig(config)).toThrow( + /Missing required config value/, + ); + }); + + it('returns implementation', () => { + const api = GoogleAnalytics4.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'App', + }); + }); + }); + + describe('integration', () => { + const searchConfig = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + virtualSearchPageView: { + mode: 'both', + searchQuery: 'term', + }, + }, + }, + }, + }); + + const configWithContentGrouping = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + contentGrouping: 'pluginId', + }, + }, + }, + }); + + const advancedConfig = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + allowedContexts: ['pluginId', 'releaseNum'], + allowedAttributes: ['extraDimension', 'extraMetric'], + }, + }, + }, + }); + + const allowAllContextsAndAttrsConfig = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + allowedContexts: ['*'], + allowedAttributes: ['*'], + }, + }, + }, + }); + + it('testing content grouping', () => { + const api = GoogleAnalytics4.fromConfig(configWithContentGrouping); + api.captureEvent({ + action: 'navigate', + subject: '/a-page', + context, + }); + + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/a-page', + category: 'App', + value: undefined, + content_group: context.pluginId, + }); + }); + + it('tracks search', () => { + const api = GoogleAnalytics4.fromConfig(searchConfig); + const expectedAction = 'search'; + const expectedLabel = 'search-term'; + const expectedValue = 42; + api.captureEvent({ + action: expectedAction, + subject: expectedLabel, + value: expectedValue, + context, + }); + expect(fnEvent).toHaveBeenCalledWith('search', { + action: 'search', + category: 'App', + label: 'search-term', + value: 42, + search_term: 'search-term', + }); + }); + + it('tracks basic event', () => { + const api = GoogleAnalytics4.fromConfig(basicValidConfig); + + const expectedAction = 'click'; + const expectedLabel = 'on something'; + const expectedValue = 42; + api.captureEvent({ + action: expectedAction, + subject: expectedLabel, + value: expectedValue, + context, + }); + + expect(fnEvent).toHaveBeenCalledWith('click', { + action: 'click', + category: context.extension, + label: 'on something', + value: expectedValue, + }); + }); + + it('captures configured custom dimensions/metrics on pageviews', () => { + const api = GoogleAnalytics4.fromConfig(advancedConfig); + api.captureEvent({ + action: 'navigate', + subject: '/a-page', + context, + }); + + 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); + + const expectedAction = 'search'; + const expectedLabel = 'some query'; + const expectedValue = 5; + api.captureEvent({ + action: expectedAction, + subject: expectedLabel, + value: expectedValue, + attributes: { + extraDimension: false, + extraMetric: 0, + }, + context, + }); + + expect(fnEvent).toHaveBeenCalledWith('search', { + action: 'search', + category: context.extension, + label: expectedLabel, + value: expectedValue, + c_pluginId: context.pluginId, + c_releaseNum: context.releaseNum, + search_term: expectedLabel, + }); + }); + + it('does not pass non-numeric data on metrics', () => { + const api = GoogleAnalytics4.fromConfig(advancedConfig); + + api.captureEvent({ + action: 'verb', + subject: 'noun', + attributes: { + extraMetric: 'not a number', + }, + context, + }); + + expect(fnEvent).not.toHaveBeenCalledWith({ + category: context.extension, + action: 'verb', + label: 'noun', + c_pluginId: context.pluginId, + c_releaseNum: context.releaseNum, + c_extraMetric: 'not a number', + }); + }); + }); + + describe('identityApi', () => { + const identityApi = { + getBackstageIdentity: jest.fn().mockResolvedValue({ + userEntityRef: 'User:default/someone', + }), + } as unknown as IdentityApi; + + it('does not set userId unless explicitly configured', async () => { + // Instantiate with identityApi and default configs. + const api = GoogleAnalytics4.fromConfig(basicValidConfig, { + identityApi, + }); + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + // Wait for any/all promises involved to settle. + await new Promise(resolve => setTimeout(resolve)); + // There should not have been a UserID set. + expect(fnSet).not.toHaveBeenCalled(); + }); + + it('sets hashed userId when identityApi is provided', async () => { + // Instantiate with identityApi and identity set to optional + const optionalConfig = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'optional', + }, + }, + }, + }); + const api = GoogleAnalytics4.fromConfig(optionalConfig, { identityApi }); + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + // Wait for any/all promises involved to settle. + await new Promise(resolve => setTimeout(resolve)); + + expect(fnSet).toHaveBeenCalledTimes(1); + expect(fnSet).toHaveBeenCalledWith({ + // String indicating userEntityRef went through expected hashing. + user_id: '557365723a64656661756c742f736f6d656f6e65', + }); + }); + + it('set custom-hashed userId when userIdTransform is provided', async () => { + const userIdTransform = jest.fn().mockResolvedValue('s0m3hash3dvalu3'); + const optionalConfig = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'optional', + }, + }, + }, + }); + const api = GoogleAnalytics4.fromConfig(optionalConfig, { + identityApi, + userIdTransform, + }); + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + // Wait for any/all promises involved to settle. + await new Promise(resolve => setTimeout(resolve)); + + // User ID should have been set after the pageview. + expect(fnSet).toHaveBeenCalledWith({ + user_id: 's0m3hash3dvalu3', + }); + expect(userIdTransform).toHaveBeenCalledWith('User:default/someone'); + }); + + it('does not set userId when identityApi is provided and ga4.identity is explicitly disabled', async () => { + // Instantiate with identityApi and identity explicitly disabled. + const disabledConfig = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'disabled', + }, + }, + }, + }); + const api = GoogleAnalytics4.fromConfig(disabledConfig, { identityApi }); + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + // Wait for any/all promises involved to settle. + await new Promise(resolve => setTimeout(resolve)); + + // A pageview should have been fired immediately. + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'App', + value: undefined, + }); + + // There should not have been a UserID set. + expect(fnSet).toHaveBeenCalledTimes(0); + }); + + it('throws error when ga4.identity is required but no identityApi is provided', async () => { + // Instantiate without identityApi and identity explicitly disabled. + const requiredConfig = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'required', + }, + }, + }, + }); + + expect(() => GoogleAnalytics4.fromConfig(requiredConfig)).toThrow(); + }); + + it('defers event capture when ga4.identity is required', async () => { + // Instantiate with identityApi and identity explicitly required. + const requiredConfig = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'required', + }, + }, + }, + }); + const api = GoogleAnalytics4.fromConfig(requiredConfig, { identityApi }); + + // Fire a pageview and an event. + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + api.captureEvent({ + action: 'test', + subject: 'some label', + context, + }); + + // Wait for any/all promises involved to settle. + await new Promise(resolve => setTimeout(resolve)); + + // User ID should have been set first. + expect(fnSet).toHaveBeenCalledWith({ + // String indicating userEntityRef went through expected hashing. + user_id: '557365723a64656661756c742f736f6d656f6e65', + }); + + // Then a pageview should have been fired with a queue time. + 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(fnEvent).toHaveBeenCalledWith('test', { + action: 'test', + timestamp_micros: expect.any(Number), + label: 'some label', + category: 'App', + value: undefined, + }); + + // And subsequent hits should not have a queue time. + api.captureEvent({ + action: 'navigate', + subject: '/page-2', + context, + }); + + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/page-2', + category: 'App', + value: undefined, + }); + }); + }); +}); diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts new file mode 100644 index 0000000000..399366b804 --- /dev/null +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts @@ -0,0 +1,283 @@ +/* + * 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, + AnalyticsContextValue, + AnalyticsEventAttributes, + AnalyticsEvent, + IdentityApi, +} from '@backstage/core-plugin-api'; +import { Config } from '@backstage/config'; +import { DeferredCapture } from '../../../util/DeferredCapture'; + +/** + * Google Analytics API provider for the Backstage Analytics API. + * @public + */ +export class GoogleAnalytics4 implements AnalyticsApi { + private readonly customUserIdTransform?: ( + userEntityRef: string, + ) => Promise; + private readonly capture: DeferredCapture; + 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 + */ + private constructor(options: { + identityApi?: IdentityApi; + userIdTransform?: 'sha-256' | ((userEntityRef: string) => Promise); + identity: string; + measurementId: string; + testMode: boolean; + debug: boolean; + contentGroupBy?: string; + allowedContexts?: string[]; + allowedAttributes?: string[]; + }) { + const { + identity, + measurementId, + identityApi, + userIdTransform = 'sha-256', + testMode, + debug, + contentGroupBy, + allowedContexts, + allowedAttributes, + } = options; + // Initialize Google Analytics. + ReactGA.initialize(measurementId, { + testMode, + gaOptions: { + debug_mode: debug, + }, + gtagOptions: { + debug_mode: debug, + }, + }); + + this.contentGroupBy = contentGroupBy; + this.allowedAttributes = allowedAttributes; + this.allowedContexts = allowedContexts; + // If identity is required, defer event capture until identity is known. + this.capture = new DeferredCapture({ defer: identity === 'required' }); + + // Allow custom userId transformation. + this.customUserIdTransform = + typeof userIdTransform === 'function' ? userIdTransform : undefined; + + // Capture user only when explicitly enabled and provided. + if (identity !== 'disabled') { + if (identityApi) { + this.setUserFrom(identityApi).then(() => { + return; + }); + } + } + } + + /** + * Instantiate a fully configured GA Analytics API implementation. + * @param config - Config object from app config + * @param options - options with identityApi and userIdTransform config + */ + static fromConfig( + config: Config, + options: { + identityApi?: IdentityApi; + userIdTransform?: + | 'sha-256' + | ((userEntityRef: string) => Promise); + } = {}, + ) { + // Get all necessary configuration. + const measurementId = config.getString('app.analytics.ga4.measurementId'); + const identity = + config.getOptionalString('app.analytics.ga4.identity') || 'disabled'; + const debug = config.getOptionalBoolean('app.analytics.ga4.debug') ?? false; + const testMode = + config.getOptionalBoolean('app.analytics.ga4.testMode') ?? false; + + const contentGroupBy = config.getOptionalString( + 'app.analytics.ga4.contentGrouping', + ); + const allowedContexts = config.getOptionalStringArray( + 'app.analytics.ga4.allowedContexts', + ); + const allowedAttributes = config.getOptionalStringArray( + 'app.analytics.ga4.allowedAttributes', + ); + + if (identity === 'required' && !options.identityApi) { + throw new Error( + 'Invalid config: identity API must be provided to deps when ga4.identity is required', + ); + } + + // Return an implementation instance. + return new GoogleAnalytics4({ + ...options, + identity, + measurementId: measurementId, + testMode, + debug, + contentGroupBy, + allowedContexts, + allowedAttributes, + }); + } + + /** + * 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 + */ + captureEvent(event: AnalyticsEvent) { + const { context, action, subject, value, attributes } = event; + const customEventData = this.setEventParameters(context, attributes); + if (this.contentGroupBy) { + customEventData.content_group = context[this.contentGroupBy]!; + } + + if (action === 'navigate' && context.extension === 'App') { + this.capture.event( + { + category: context.extension || 'App', + action: 'page_view', + label: subject, + value, + }, + customEventData, + ); + return; + } + + if (action === 'search') { + customEventData.search_term = subject; + } + + this.capture.event( + { + category: context.extension || 'App', + action, + label: subject, + value, + }, + customEventData, + ); + } + + /** + * Returns an object of dimensions/metrics given an Analytics Context and an + * Event Attributes, e.g. { c_pluginId: "some value", a_attribute1: 42 } + * @param context analytics context object + * @param attributes additional analytics event attributes + */ + private setEventParameters( + context: AnalyticsContextValue, + attributes: AnalyticsEventAttributes = {}, + ) { + const customEventParameters: { + [x: string]: string | number | boolean | undefined; + } = {}; + + const contextKeys = + this.allowedContexts?.join('') === '*' + ? Object.keys(context) + : this.allowedContexts; + + contextKeys?.forEach(ctx => { + if (context[ctx]) { + customEventParameters[`c_${ctx}`] = context[ctx]; + } + }); + + const attrKeys = + this.allowedAttributes?.join('') === '*' + ? Object.keys(attributes) + : this.allowedAttributes; + attrKeys?.forEach(attr => { + if (attributes[attr]) { + customEventParameters[`a_${attr}`] = attributes[attr]; + } + }); + + return customEventParameters; + } + + /** + * Sets the GA userId, based on the `userEntityRef` set on the backstage + * identity loaded from a given Backstage Identity API instance. Because + * Google forbids sending any PII (including on the userId field), we hash + * the entire `userEntityRef` on behalf of integrators: + * + * - With value `User:default/name`, userId becomes `sha256(User:default/name)` + * + * If an integrator wishes to use an alternative hashing mechanism or an + * entirely different value, they may do so by passing a `userIdTransform` + * function alongside the `identityApi` to `GoogleAnalytics.fromConfig()`. + * This function receives the `userEntityRef` as an argument and should + * resolve to a hashed version of whatever identifier they choose. + * + * Note: this feature requires that an integrator has set up a Google + * Analytics User ID view in the property used to track Backstage. + * @param identityApi IdentityApi object + */ + private async setUserFrom(identityApi: IdentityApi) { + const { userEntityRef } = await identityApi.getBackstageIdentity(); + + // Prevent PII from being passed to Google Analytics. + const userId = await this.getPrivateUserId(userEntityRef); + + // Set the user ID. + ReactGA.set({ user_id: userId }); + + // Notify the deferred capture mechanism that it may proceed. + this.capture.setReady(); + } + + /** + * Returns a PII-free (according to Google's terms of service) user ID for + * use in Google Analytics. + * @param userEntityRef user entity as string + */ + private getPrivateUserId(userEntityRef: string): Promise { + // Allow integrators to provide their own hashing transformer. + if (this.customUserIdTransform) { + return this.customUserIdTransform(userEntityRef); + } + + return this.hash(userEntityRef); + } + + /** + * Simple hash function; relies on web cryptography + the sha-256 algorithm. + * @param value value to be hashed + */ + private async hash(value: string): Promise { + const digest = await window.crypto.subtle.digest( + 'sha-256', + new TextEncoder().encode(value), + ); + const hashArray = Array.from(new Uint8Array(digest)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + } +} diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/index.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/index.ts new file mode 100644 index 0000000000..2b21af1b1b --- /dev/null +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/index.ts @@ -0,0 +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'; diff --git a/plugins/analytics-module-ga4/src/index.ts b/plugins/analytics-module-ga4/src/index.ts new file mode 100644 index 0000000000..0adf114679 --- /dev/null +++ b/plugins/analytics-module-ga4/src/index.ts @@ -0,0 +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 * from './apis/implementations/AnalyticsApi'; diff --git a/plugins/analytics-module-ga4/src/setupTests.ts b/plugins/analytics-module-ga4/src/setupTests.ts new file mode 100644 index 0000000000..4ed20ac097 --- /dev/null +++ b/plugins/analytics-module-ga4/src/setupTests.ts @@ -0,0 +1,34 @@ +/* + * 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'; + +// eslint-disable-next-line no-restricted-imports +import { TextEncoder } from 'util'; + +// Mock browser crypto.subtle.digest method for sha-256 hashing. +Object.defineProperty(global.self, 'crypto', { + value: { + subtle: { + digest: (_algo: string, data: Uint8Array): ArrayBuffer => data.buffer, + }, + }, +}); + +// Also used in browser-based APIs for hashing. +Object.defineProperty(global.self, 'TextEncoder', { + value: TextEncoder, +}); diff --git a/plugins/analytics-module-ga4/src/util/DeferredCapture.ts b/plugins/analytics-module-ga4/src/util/DeferredCapture.ts new file mode 100644 index 0000000000..c2a1629d2a --- /dev/null +++ b/plugins/analytics-module-ga4/src/util/DeferredCapture.ts @@ -0,0 +1,96 @@ +/* + * 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: { + [x: string]: any; + }; +}; + +/** + * A wrapper around ReactGA that can optionally handle latent capture logic. + * + * - When defer is `false`, event data is sent directly to GA. + * - When defer is `true`, event data is queued (with a timestamp), so that it + * can be sent to GA once externally indicated to be ready. This relies on + * the `qt` or `queueTime` parameter of the Measurement Protocol. + * + * @see https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#qt + */ +export class DeferredCapture { + /** + * Queue of deferred hits to be processed when ready. When undefined, hits + * can safely be sent without delay. + */ + private queue: Hit[] | undefined; + + /** + * constructor for creating the DeferredCapture object + * @param defer type of {defer: boolean} + */ + constructor({ defer = false }: { defer: boolean }) { + this.queue = defer ? [] : undefined; + } + + /** + * Indicates that deferred capture may now proceed. + */ + setReady() { + if (this.queue) { + this.queue.forEach(this.sendDeferred); + this.queue = undefined; + } + } + + /** + * Either forwards the event directly to GA, or (if configured) enqueues the + * event hit to be captured when ready. + * @param eventDetails type of UaEventOptions object + * @param metadata any object that can be passed as additional parameter to the event + */ + event(eventDetails: UaEventOptions, metadata: any = {}) { + const data = { + ...eventDetails, + ...metadata, + }; + if (this.queue) { + this.queue.push({ + hitType: eventDetails.action, + data: { + ...data, + timestamp_micros: Date.now() * 1000, + }, + }); + return; + } + ReactGA.event(eventDetails.action, data); + } + + /** + * Sends a given hit to GA, decorated with the correct queue time. + * @param hit Hit object + */ + private sendDeferred(hit: Hit) { + // Send the hit with the appropriate queue time (`qt`). + ReactGA.event(hit.hitType, { + ...hit.data, + }); + } +} diff --git a/plugins/analytics-module-ga4/src/util/index.ts b/plugins/analytics-module-ga4/src/util/index.ts new file mode 100644 index 0000000000..267e7a2e62 --- /dev/null +++ b/plugins/analytics-module-ga4/src/util/index.ts @@ -0,0 +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'; diff --git a/yarn.lock b/yarn.lock index 1e7647118f..e366d79fa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4468,6 +4468,36 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-analytics-module-ga4@workspace:plugins/analytics-module-ga4": + version: 0.0.0-use.local + resolution: "@backstage/plugin-analytics-module-ga4@workspace:plugins/analytics-module-ga4" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@testing-library/dom": ^8.0.0 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + "@types/jest": ^28.1.3 + "@types/node": ^16.11.26 + "@types/react": ^16.13.1 || ^17.0.0 + cross-fetch: ^3.1.5 + msw: ^1.0.0 + react-ga4: ^2.0.0 + react-use: ^17.2.4 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + react-dom: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + languageName: unknown + linkType: soft + "@backstage/plugin-analytics-module-ga@workspace:plugins/analytics-module-ga": version: 0.0.0-use.local resolution: "@backstage/plugin-analytics-module-ga@workspace:plugins/analytics-module-ga" @@ -34528,6 +34558,13 @@ __metadata: languageName: node linkType: hard +"react-ga4@npm:^2.0.0": + version: 2.1.0 + resolution: "react-ga4@npm:2.1.0" + checksum: f7fb41141418d4ad14756f1126a1e9958db37d4d84ae6cd798043dc03a390b6dba74d69311af0349f0b9580a43bda8930138194ccc29c4100efe446e2d6eb057 + languageName: node + linkType: hard + "react-ga@npm:^3.3.0": version: 3.3.1 resolution: "react-ga@npm:3.3.1"