diff --git a/.changeset/mighty-cows-greet.md b/.changeset/mighty-cows-greet.md new file mode 100644 index 0000000000..2d85ce371c --- /dev/null +++ b/.changeset/mighty-cows-greet.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-analytics-module-ga4': major +--- + +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..7e463f2382 --- /dev/null +++ b/plugins/analytics-module-ga4/.eslintrc.js @@ -0,0 +1,47 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + plugins: ['jsdoc'], + rules: { + 'jsdoc/check-access': 1, + 'jsdoc/check-alignment': 1, + 'jsdoc/check-param-names': ['warn', { checkDestructured: false }], + 'jsdoc/check-property-names': 1, + 'jsdoc/check-tag-names': ['warn', { definedTags: ['visibility'] }], + 'jsdoc/check-types': 1, + 'jsdoc/check-values': 1, + 'jsdoc/empty-tags': 1, + 'jsdoc/implements-on-classes': 1, + 'jsdoc/multiline-blocks': 1, + 'jsdoc/no-multi-asterisks': 1, + 'jsdoc/no-types': 1, + 'jsdoc/no-undefined-types': 1, + 'jsdoc/require-asterisk-prefix': 1, + 'jsdoc/require-description': 1, + 'jsdoc/require-jsdoc': [ + 'warn', + { + publicOnly: true, + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + ArrowFunctionExpression: true, + FunctionExpression: true, + }, + contexts: ['ExportNamedDeclaration > VariableDeclaration'], + }, + ], + 'jsdoc/require-param': ['warn', { checkDestructured: false }], + 'jsdoc/require-param-description': 1, + 'jsdoc/require-param-name': 1, + 'jsdoc/require-property': 1, + 'jsdoc/require-property-description': 1, + 'jsdoc/require-property-name': 1, + 'jsdoc/require-property-type': 1, + 'jsdoc/valid-types': 1, + }, + settings: { + jsdoc: { + tagNamePreference: { 'tag constructor': 'constructor' }, + }, + }, +}); 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..99423c2ce7 --- /dev/null +++ b/plugins/analytics-module-ga4/README.md @@ -0,0 +1,214 @@ +# 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 @devx-discover/plugin-analytics-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 '@devx-discover/plugin-analytics-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 Universal Analytics 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 +strongly recommend configuring at least one [custom dimension][what-is-a-custom-dimension] +to capture Plugin IDs associated with events, including page views. + +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_`. + +```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: + +See the [Developer documentation](development.md) for instructions on how to get started developing this plugin. + +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..063c6fde88 --- /dev/null +++ b/plugins/analytics-module-ga4/api-report.md @@ -0,0 +1,30 @@ +## 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 { BackstagePlugin } from '@backstage/core-plugin-api'; +import { Config } from '@backstage/config'; +import { IdentityApi } from '@backstage/core-plugin-api'; + +// @public @deprecated (undocumented) +export const analyticsModuleGA4: BackstagePlugin<{}, {}, {}>; + +// @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..e6fede1262 --- /dev/null +++ b/plugins/analytics-module-ga4/config.d.ts @@ -0,0 +1,128 @@ +/* + * 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'; + + /** + * 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 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?: Array; + /** + * + * 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?: Array; + }; + }; + }; +} diff --git a/plugins/analytics-module-ga4/package.json b/plugins/analytics-module-ga4/package.json new file mode 100644 index 0000000000..35c99d0e3f --- /dev/null +++ b/plugins/analytics-module-ga4/package.json @@ -0,0 +1,58 @@ +{ + "name": "@backstage/plugin-analytics-module-ga4", + "version": "0.1.19-next.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "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", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "clean": "backstage-cli package clean" + }, + "dependencies": { + "@backstage/config": "^1.0.6", + "@backstage/core-components": "^0.12.4", + "@backstage/core-plugin-api": "^1.4.0", + "@backstage/theme": "^0.2.16", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "react-ga4": "^2.0.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.22.3", + "@backstage/core-app-api": "^1.5.0", + "@backstage/dev-utils": "^1.0.12", + "@backstage/test-utils": "^1.2.5", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/jest": "^26.0.7", + "@types/node": "^16.11.26", + "cross-fetch": "^3.1.5", + "msw": "^0.44.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..2de8053712 --- /dev/null +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts @@ -0,0 +1,419 @@ +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; +}); + +const fnSend = jest.spyOn(ReactGA, 'send'); +// @ts-ignore +fnSend.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(fnSend).toHaveBeenCalledWith({ + hitType: 'pageview', + page: '/', + }); + }); + }); + + 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'], + }, + }, + }, + }); + + it('testing content grouping', () => { + const api = GoogleAnalytics4.fromConfig(configWithContentGrouping); + api.captureEvent({ + action: 'navigate', + subject: '/a-page', + context, + }); + + expect(fnSend).toHaveBeenCalledWith({ + hitType: 'pageview', + page: '/a-page', + 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(fnSend).toHaveBeenCalledWith({ + hitType: 'event', + eventAction: 'search', + eventCategory: 'App', + eventLabel: 'search-term', + eventValue: 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(fnSend).toHaveBeenCalledWith({ + hitType: 'event', + eventCategory: context.extension, + eventAction: expectedAction, + eventLabel: expectedLabel, + eventValue: expectedValue, + }); + }); + + it('captures configured custom dimensions/metrics on pageviews', () => { + const api = GoogleAnalytics4.fromConfig(advancedConfig); + api.captureEvent({ + action: 'navigate', + subject: '/a-page', + context, + }); + + expect(fnSend).toHaveBeenCalledWith({ + hitType: 'pageview', + page: '/a-page', + c_pluginId: context.pluginId, + c_releaseNum: context.releaseNum, + }); + }); + + 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(fnSend).toHaveBeenCalledWith({ + hitType: 'event', + eventCategory: context.extension, + eventAction: expectedAction, + eventLabel: expectedLabel, + eventValue: 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({ + eventCategory: context.extension, + eventAction: 'verb', + eventLabel: '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(fnSend).toHaveBeenCalledWith({ + hitType: 'pageview', + page: '/', + }); + + // 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(fnSend).toHaveBeenCalledWith({ + hitType: 'pageview', + page: '/', + timestamp_micros: expect.any(Number), + }); + + // Then an event should have been fired with a queue time. + expect(fnSend).toHaveBeenCalledWith({ + hitType: 'event', + timestamp_micros: expect.any(Number), + eventAction: 'test', + eventCategory: 'App', + eventLabel: 'some label', + eventValue: undefined, + }); + + // And subsequent hits should not have a queue time. + api.captureEvent({ + action: 'navigate', + subject: '/page-2', + context, + }); + + expect(fnSend).toHaveBeenCalledWith({ + hitType: 'pageview', + page: '/page-2', + }); + }); + }); +}); 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..8f5ad65857 --- /dev/null +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts @@ -0,0 +1,252 @@ +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'; + +/** + * 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.allowedContexts = allowedContexts; + this.allowedAttributes = allowedAttributes; + + // 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.pageview(subject, 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; + } = {}; + + this.allowedContexts?.forEach(ctx => { + if (context[ctx]) { + customEventParameters[`c_${ctx}`] = context[ctx]; + } + }); + + this.allowedAttributes?.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 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..a9773197ab --- /dev/null +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/index.ts @@ -0,0 +1 @@ +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..dc712f25ef --- /dev/null +++ b/plugins/analytics-module-ga4/src/index.ts @@ -0,0 +1,2 @@ +export { analyticsModuleGA4 } from './plugin'; +export * from './apis/implementations/AnalyticsApi'; diff --git a/plugins/analytics-module-ga4/src/plugin.test.ts b/plugins/analytics-module-ga4/src/plugin.test.ts new file mode 100644 index 0000000000..43e3104167 --- /dev/null +++ b/plugins/analytics-module-ga4/src/plugin.test.ts @@ -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 { analyticsModuleGA4 } from './plugin'; + +describe('google-analytics', () => { + it('should export plugin', () => { + expect(analyticsModuleGA4).toBeDefined(); + }); +}); diff --git a/plugins/analytics-module-ga4/src/plugin.ts b/plugins/analytics-module-ga4/src/plugin.ts new file mode 100644 index 0000000000..0cd1e8e5fb --- /dev/null +++ b/plugins/analytics-module-ga4/src/plugin.ts @@ -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 { 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', +}); 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..9c829d3950 --- /dev/null +++ b/plugins/analytics-module-ga4/src/util/DeferredCapture.ts @@ -0,0 +1,112 @@ +import ReactGA from 'react-ga4'; + +import { UaEventOptions } from 'react-ga4/types/ga4'; + +type Hit = { + data: { + hitType: 'pageview' | 'event'; + [x: string]: any; + }; +}; + +const PageViewEvent = 'pageview'; + +/** + * 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 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. + * @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 = { + hitType: 'event', + eventCategory: eventDetails.category, + eventLabel: eventDetails.label!, + eventAction: eventDetails.action, + eventValue: eventDetails.value, + ...metadata, + }; + if (this.queue) { + this.queue.push({ + data: { + ...data, + timestamp_micros: Date.now() * 1000, + }, + }); + return; + } + ReactGA.send(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.send({ + ...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..14c3ba7420 --- /dev/null +++ b/plugins/analytics-module-ga4/src/util/index.ts @@ -0,0 +1 @@ +export { DeferredCapture } from './DeferredCapture';