From 22b46f7f56225572188161c2bdfa19ecd2541a27 Mon Sep 17 00:00:00 2001 From: "Ramakrishnan, Sriram" Date: Tue, 28 Feb 2023 08:45:14 -0500 Subject: [PATCH 01/10] Analytics Module to support Google Analytics 4 Remove virtual page view. Handle events by send those directly to react-ga4 with hitType of event. That allows to add custom parameters in the event. Added allowedContexts and allowedAttributes config. rename trackingId to measurementId rename trackingId to measurementId generate api-report fix for vale spelling checks Signed-off-by: sriram ramakrishnan --- .changeset/mighty-cows-greet.md | 5 + plugins/analytics-module-ga4/.eslintrc.js | 47 ++ plugins/analytics-module-ga4/CHANGELOG.md | 1 + plugins/analytics-module-ga4/README.md | 214 +++++++++ plugins/analytics-module-ga4/api-report.md | 30 ++ plugins/analytics-module-ga4/config.d.ts | 128 ++++++ plugins/analytics-module-ga4/package.json | 58 +++ .../AnalyticsApi/GoogleAnalytics4.test.ts | 419 ++++++++++++++++++ .../AnalyticsApi/GoogleAnalytics4.ts | 252 +++++++++++ .../implementations/AnalyticsApi/index.ts | 1 + plugins/analytics-module-ga4/src/index.ts | 2 + .../analytics-module-ga4/src/plugin.test.ts | 22 + plugins/analytics-module-ga4/src/plugin.ts | 26 ++ .../analytics-module-ga4/src/setupTests.ts | 34 ++ .../src/util/DeferredCapture.ts | 112 +++++ .../analytics-module-ga4/src/util/index.ts | 1 + 16 files changed, 1352 insertions(+) create mode 100644 .changeset/mighty-cows-greet.md create mode 100644 plugins/analytics-module-ga4/.eslintrc.js create mode 100644 plugins/analytics-module-ga4/CHANGELOG.md create mode 100644 plugins/analytics-module-ga4/README.md create mode 100644 plugins/analytics-module-ga4/api-report.md create mode 100644 plugins/analytics-module-ga4/config.d.ts create mode 100644 plugins/analytics-module-ga4/package.json create mode 100644 plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts create mode 100644 plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts create mode 100644 plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/index.ts create mode 100644 plugins/analytics-module-ga4/src/index.ts create mode 100644 plugins/analytics-module-ga4/src/plugin.test.ts create mode 100644 plugins/analytics-module-ga4/src/plugin.ts create mode 100644 plugins/analytics-module-ga4/src/setupTests.ts create mode 100644 plugins/analytics-module-ga4/src/util/DeferredCapture.ts create mode 100644 plugins/analytics-module-ga4/src/util/index.ts 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'; From db64baf57ba21411ad9ee7d663496fb372986b93 Mon Sep 17 00:00:00 2001 From: sriram ramakrishnan Date: Wed, 29 Mar 2023 19:27:36 -0400 Subject: [PATCH 02/10] Use standard event names for page_view Remove fnSend and send all captured events through ga4-events Change the version to minor Fix package.json Changes to test to make it pass Signed-off-by: sriram ramakrishnan --- .changeset/mighty-cows-greet.md | 2 +- plugins/analytics-module-ga4/.eslintrc.js | 48 +---- plugins/analytics-module-ga4/README.md | 5 +- plugins/analytics-module-ga4/config.d.ts | 36 +--- plugins/analytics-module-ga4/package.json | 32 +-- .../AnalyticsApi/GoogleAnalytics4.test.ts | 190 ++++++++++++------ .../AnalyticsApi/GoogleAnalytics4.ts | 51 ++++- .../implementations/AnalyticsApi/index.ts | 15 ++ plugins/analytics-module-ga4/src/index.ts | 16 +- .../analytics-module-ga4/src/plugin.test.ts | 22 -- plugins/analytics-module-ga4/src/plugin.ts | 26 --- .../src/util/DeferredCapture.ts | 56 ++---- .../analytics-module-ga4/src/util/index.ts | 15 ++ 13 files changed, 262 insertions(+), 252 deletions(-) delete mode 100644 plugins/analytics-module-ga4/src/plugin.test.ts delete mode 100644 plugins/analytics-module-ga4/src/plugin.ts diff --git a/.changeset/mighty-cows-greet.md b/.changeset/mighty-cows-greet.md index 2d85ce371c..2e871db0c6 100644 --- a/.changeset/mighty-cows-greet.md +++ b/.changeset/mighty-cows-greet.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-analytics-module-ga4': major +'@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 index 7e463f2382..e2a53a6ad2 100644 --- a/plugins/analytics-module-ga4/.eslintrc.js +++ b/plugins/analytics-module-ga4/.eslintrc.js @@ -1,47 +1 @@ -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' }, - }, - }, -}); +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/analytics-module-ga4/README.md b/plugins/analytics-module-ga4/README.md index 99423c2ce7..7f4ce73b83 100644 --- a/plugins/analytics-module-ga4/README.md +++ b/plugins/analytics-module-ga4/README.md @@ -62,9 +62,8 @@ backend: ## 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. +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`. diff --git a/plugins/analytics-module-ga4/config.d.ts b/plugins/analytics-module-ga4/config.d.ts index e6fede1262..d90ccb5938 100644 --- a/plugins/analytics-module-ga4/config.d.ts +++ b/plugins/analytics-module-ga4/config.d.ts @@ -46,38 +46,6 @@ 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 to log analytics debug statements to the console. * Defaults to false. @@ -114,14 +82,14 @@ export interface Config { * context-name will be prefixed by c_, for example, pluginId will be c_pluginId in the event. * */ - allowedContexts?: Array; + 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?: Array; + allowedAttributes?: string[] | ['*']; }; }; }; diff --git a/plugins/analytics-module-ga4/package.json b/plugins/analytics-module-ga4/package.json index 35c99d0e3f..df77a3a9de 100644 --- a/plugins/analytics-module-ga4/package.json +++ b/plugins/analytics-module-ga4/package.json @@ -1,10 +1,9 @@ { "name": "@backstage/plugin-analytics-module-ga4", - "version": "0.1.19-next.1", + "version": "0.0.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -18,37 +17,40 @@ "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", + "@backstage/config": "workspace:^", + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/theme": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", - "@material-ui/lab": "4.0.0-alpha.57", + "@material-ui/lab": "4.0.0-alpha.61", "react-ga4": "^2.0.0", "react-use": "^17.2.4" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0" + "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": "^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", + "@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": "^26.0.7", "@types/node": "^16.11.26", + "@types/react": "^16.13.1 || ^17.0.0", "cross-fetch": "^3.1.5", - "msw": "^0.44.0" + "msw": "^1.0.0" }, "files": [ "dist", 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 index 2de8053712..16db7075b3 100644 --- a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts @@ -1,3 +1,18 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import { ConfigReader } from '@backstage/config'; import { IdentityApi } from '@backstage/core-plugin-api'; import ReactGA from 'react-ga4'; @@ -19,12 +34,6 @@ fnSet.mockImplementation((fieldObject: any) => { return; }); -const fnSend = jest.spyOn(ReactGA, 'send'); -// @ts-ignore -fnSend.mockImplementation((fieldObject: any) => { - return; -}); - afterEach(() => { jest.clearAllMocks(); }); @@ -38,7 +47,9 @@ describe('GoogleAnalytics4', () => { }; const measurementId = 'G-000000-0'; const basicValidConfig = new ConfigReader({ - app: { analytics: { ga4: { measurementId: measurementId, testMode: true } } }, + app: { + analytics: { ga4: { measurementId: measurementId, testMode: true } }, + }, }); describe('fromConfig', () => { @@ -59,9 +70,10 @@ describe('GoogleAnalytics4', () => { subject: '/', context, }); - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'pageview', - page: '/', + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'App', }); }); }); @@ -107,6 +119,19 @@ describe('GoogleAnalytics4', () => { }, }); + const allowAllContextsAndAttrsConfig = new ConfigReader({ + app: { + analytics: { + ga4: { + measurementId: measurementId, + testMode: true, + allowedContexts: ['*'], + allowedAttributes: ['*'], + }, + }, + }, + }); + it('testing content grouping', () => { const api = GoogleAnalytics4.fromConfig(configWithContentGrouping); api.captureEvent({ @@ -115,9 +140,11 @@ describe('GoogleAnalytics4', () => { context, }); - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'pageview', - page: '/a-page', + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/a-page', + category: 'App', + value: undefined, content_group: context.pluginId, }); }); @@ -133,12 +160,11 @@ describe('GoogleAnalytics4', () => { value: expectedValue, context, }); - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'event', - eventAction: 'search', - eventCategory: 'App', - eventLabel: 'search-term', - eventValue: 42, + expect(fnEvent).toHaveBeenCalledWith('search', { + action: 'search', + category: 'App', + label: 'search-term', + value: 42, search_term: 'search-term', }); }); @@ -156,12 +182,11 @@ describe('GoogleAnalytics4', () => { context, }); - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'event', - eventCategory: context.extension, - eventAction: expectedAction, - eventLabel: expectedLabel, - eventValue: expectedValue, + expect(fnEvent).toHaveBeenCalledWith('click', { + action: 'click', + category: context.extension, + label: 'on something', + value: expectedValue, }); }); @@ -173,14 +198,41 @@ describe('GoogleAnalytics4', () => { context, }); - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'pageview', - page: '/a-page', + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/a-page', + category: 'App', + value: undefined, c_pluginId: context.pluginId, c_releaseNum: context.releaseNum, }); }); + it('captures all dimensions/metrics on pageviews', () => { + const api = GoogleAnalytics4.fromConfig(allowAllContextsAndAttrsConfig); + api.captureEvent({ + action: 'navigate', + subject: '/a-page', + context, + attributes: { + 'attr-1': 'attr-value-1', + 'attr-2': 'attr-value-2', + }, + }); + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + category: 'App', + label: '/a-page', + value: undefined, + c_pluginId: context.pluginId, + c_releaseNum: context.releaseNum, + c_routeRef: context.routeRef, + c_extension: context.extension, + 'a_attr-1': 'attr-value-1', + 'a_attr-2': 'attr-value-2', + }); + }); + it('captures configured custom dimensions/metrics on events', () => { const api = GoogleAnalytics4.fromConfig(advancedConfig); @@ -198,12 +250,11 @@ describe('GoogleAnalytics4', () => { context, }); - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'event', - eventCategory: context.extension, - eventAction: expectedAction, - eventLabel: expectedLabel, - eventValue: expectedValue, + expect(fnEvent).toHaveBeenCalledWith('search', { + action: 'search', + category: context.extension, + label: expectedLabel, + value: expectedValue, c_pluginId: context.pluginId, c_releaseNum: context.releaseNum, search_term: expectedLabel, @@ -223,9 +274,9 @@ describe('GoogleAnalytics4', () => { }); expect(fnEvent).not.toHaveBeenCalledWith({ - eventCategory: context.extension, - eventAction: 'verb', - eventLabel: 'noun', + category: context.extension, + action: 'verb', + label: 'noun', c_pluginId: context.pluginId, c_releaseNum: context.releaseNum, c_extraMetric: 'not a number', @@ -262,7 +313,11 @@ describe('GoogleAnalytics4', () => { const optionalConfig = new ConfigReader({ app: { analytics: { - ga4: { measurementId: measurementId, testMode: true, identity: 'optional' }, + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'optional', + }, }, }, }); @@ -288,7 +343,11 @@ describe('GoogleAnalytics4', () => { const optionalConfig = new ConfigReader({ app: { analytics: { - ga4: { measurementId: measurementId, testMode: true, identity: 'optional' }, + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'optional', + }, }, }, }); @@ -317,7 +376,11 @@ describe('GoogleAnalytics4', () => { const disabledConfig = new ConfigReader({ app: { analytics: { - ga4: { measurementId: measurementId, testMode: true, identity: 'disabled' }, + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'disabled', + }, }, }, }); @@ -332,9 +395,11 @@ describe('GoogleAnalytics4', () => { await new Promise(resolve => setTimeout(resolve)); // A pageview should have been fired immediately. - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'pageview', - page: '/', + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'App', + value: undefined, }); // There should not have been a UserID set. @@ -346,7 +411,11 @@ describe('GoogleAnalytics4', () => { const requiredConfig = new ConfigReader({ app: { analytics: { - ga4: { measurementId: measurementId, testMode: true, identity: 'required' }, + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'required', + }, }, }, }); @@ -359,7 +428,11 @@ describe('GoogleAnalytics4', () => { const requiredConfig = new ConfigReader({ app: { analytics: { - ga4: { measurementId: measurementId, testMode: true, identity: 'required' }, + ga4: { + measurementId: measurementId, + testMode: true, + identity: 'required', + }, }, }, }); @@ -387,20 +460,21 @@ describe('GoogleAnalytics4', () => { }); // Then a pageview should have been fired with a queue time. - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'pageview', - page: '/', + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', timestamp_micros: expect.any(Number), + value: undefined, + category: 'App', }); // Then an event should have been fired with a queue time. - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'event', + expect(fnEvent).toHaveBeenCalledWith('test', { + action: 'test', timestamp_micros: expect.any(Number), - eventAction: 'test', - eventCategory: 'App', - eventLabel: 'some label', - eventValue: undefined, + label: 'some label', + category: 'App', + value: undefined, }); // And subsequent hits should not have a queue time. @@ -410,9 +484,11 @@ describe('GoogleAnalytics4', () => { context, }); - expect(fnSend).toHaveBeenCalledWith({ - hitType: 'pageview', - page: '/page-2', + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/page-2', + category: 'App', + value: undefined, }); }); }); diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts index 8f5ad65857..e38a0db1f8 100644 --- a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts @@ -1,3 +1,18 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import ReactGA from 'react-ga4'; import { AnalyticsApi, @@ -7,7 +22,7 @@ import { IdentityApi, } from '@backstage/core-plugin-api'; import { Config } from '@backstage/config'; -import { DeferredCapture } from '../../../util'; +import { DeferredCapture } from '../../../util/DeferredCapture'; /** * Google Analytics API provider for the Backstage Analytics API. @@ -21,7 +36,6 @@ export class GoogleAnalytics4 implements AnalyticsApi { private readonly contentGroupBy?: string; private readonly allowedContexts?: string[]; private readonly allowedAttributes?: string[]; - /** * Instantiate the implementation and initialize ReactGA. * @param options initializes Google Analytics module with the config @@ -60,9 +74,8 @@ export class GoogleAnalytics4 implements AnalyticsApi { }); this.contentGroupBy = contentGroupBy; - this.allowedContexts = allowedContexts; this.allowedAttributes = allowedAttributes; - + this.allowedContexts = allowedContexts; // If identity is required, defer event capture until identity is known. this.capture = new DeferredCapture({ defer: identity === 'required' }); @@ -82,8 +95,8 @@ export class GoogleAnalytics4 implements AnalyticsApi { /** * Instantiate a fully configured GA Analytics API implementation. - * @param config - Config object from app config - * @param options - options with identityApi and userIdTransform config + * @param config Config object from app config + * @param options options with identityApi and userIdTransform config */ static fromConfig( config: Config, @@ -135,7 +148,7 @@ export class GoogleAnalytics4 implements AnalyticsApi { * Primary event capture implementation. Handles core navigate event as a * pageview and the rest as custom events. All custom dimensions/metrics are * applied as they should be (set on pageview, merged object on events). - * @param event - AnalyticsEvent type captured + * @param event AnalyticsEvent type captured */ captureEvent(event: AnalyticsEvent) { const { context, action, subject, value, attributes } = event; @@ -145,7 +158,15 @@ export class GoogleAnalytics4 implements AnalyticsApi { } if (action === 'navigate' && context.extension === 'App') { - this.capture.pageview(subject, customEventData); + this.capture.event( + { + category: context.extension || 'App', + action: 'page_view', + label: subject, + value, + }, + customEventData, + ); return; } @@ -178,17 +199,27 @@ export class GoogleAnalytics4 implements AnalyticsApi { [x: string]: string | number | boolean | undefined; } = {}; - this.allowedContexts?.forEach(ctx => { + const contextKeys = + this.allowedContexts?.join('') === '*' + ? Object.keys(context) + : this.allowedContexts; + + contextKeys?.forEach(ctx => { if (context[ctx]) { customEventParameters[`c_${ctx}`] = context[ctx]; } }); - this.allowedAttributes?.forEach(attr => { + const attrKeys = + this.allowedAttributes?.join('') === '*' + ? Object.keys(attributes) + : this.allowedAttributes; + attrKeys?.forEach(attr => { if (attributes[attr]) { customEventParameters[`a_${attr}`] = attributes[attr]; } }); + return customEventParameters; } diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/index.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/index.ts index a9773197ab..2b21af1b1b 100644 --- a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/index.ts +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/index.ts @@ -1 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ export { GoogleAnalytics4 } from './GoogleAnalytics4'; diff --git a/plugins/analytics-module-ga4/src/index.ts b/plugins/analytics-module-ga4/src/index.ts index dc712f25ef..0adf114679 100644 --- a/plugins/analytics-module-ga4/src/index.ts +++ b/plugins/analytics-module-ga4/src/index.ts @@ -1,2 +1,16 @@ -export { analyticsModuleGA4 } from './plugin'; +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ export * from './apis/implementations/AnalyticsApi'; diff --git a/plugins/analytics-module-ga4/src/plugin.test.ts b/plugins/analytics-module-ga4/src/plugin.test.ts deleted file mode 100644 index 43e3104167..0000000000 --- a/plugins/analytics-module-ga4/src/plugin.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { analyticsModuleGA4 } from './plugin'; - -describe('google-analytics', () => { - it('should export plugin', () => { - expect(analyticsModuleGA4).toBeDefined(); - }); -}); diff --git a/plugins/analytics-module-ga4/src/plugin.ts b/plugins/analytics-module-ga4/src/plugin.ts deleted file mode 100644 index 0cd1e8e5fb..0000000000 --- a/plugins/analytics-module-ga4/src/plugin.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { createPlugin } from '@backstage/core-plugin-api'; - -/** - * @deprecated Importing and including this plugin in an app has no effect. - * This will be removed in a future release. - * - * @public - */ -export const analyticsModuleGA4 = createPlugin({ - id: 'analytics-provider-ga4', -}); diff --git a/plugins/analytics-module-ga4/src/util/DeferredCapture.ts b/plugins/analytics-module-ga4/src/util/DeferredCapture.ts index 9c829d3950..c2a1629d2a 100644 --- a/plugins/analytics-module-ga4/src/util/DeferredCapture.ts +++ b/plugins/analytics-module-ga4/src/util/DeferredCapture.ts @@ -1,16 +1,29 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import ReactGA from 'react-ga4'; import { UaEventOptions } from 'react-ga4/types/ga4'; type Hit = { + hitType: string; data: { - hitType: 'pageview' | 'event'; [x: string]: any; }; }; -const PageViewEvent = 'pageview'; - /** * A wrapper around ReactGA that can optionally handle latent capture logic. * @@ -46,32 +59,6 @@ export class DeferredCapture { } } - /** - * Either forwards the pageview directly to GA, or (if configured) enqueues - * the pageview hit to be captured when ready. - * @param path pageview path - * @param metadata any object that can be passed as additional parameter to the event - */ - pageview(path: string, metadata: any = {}) { - if (this.queue) { - this.queue.push({ - data: { - hitType: PageViewEvent, - timestamp_micros: Date.now() * 1000, - page: path, - ...metadata, - }, - }); - return; - } - - ReactGA.send({ - hitType: PageViewEvent, - page: path, - ...metadata, - }); - } - /** * Either forwards the event directly to GA, or (if configured) enqueues the * event hit to be captured when ready. @@ -80,15 +67,12 @@ export class DeferredCapture { */ event(eventDetails: UaEventOptions, metadata: any = {}) { const data = { - hitType: 'event', - eventCategory: eventDetails.category, - eventLabel: eventDetails.label!, - eventAction: eventDetails.action, - eventValue: eventDetails.value, + ...eventDetails, ...metadata, }; if (this.queue) { this.queue.push({ + hitType: eventDetails.action, data: { ...data, timestamp_micros: Date.now() * 1000, @@ -96,7 +80,7 @@ export class DeferredCapture { }); return; } - ReactGA.send(data); + ReactGA.event(eventDetails.action, data); } /** @@ -105,7 +89,7 @@ export class DeferredCapture { */ private sendDeferred(hit: Hit) { // Send the hit with the appropriate queue time (`qt`). - ReactGA.send({ + ReactGA.event(hit.hitType, { ...hit.data, }); } diff --git a/plugins/analytics-module-ga4/src/util/index.ts b/plugins/analytics-module-ga4/src/util/index.ts index 14c3ba7420..267e7a2e62 100644 --- a/plugins/analytics-module-ga4/src/util/index.ts +++ b/plugins/analytics-module-ga4/src/util/index.ts @@ -1 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ export { DeferredCapture } from './DeferredCapture'; From baa666378300f1e76edc2064309103a10b1066cd Mon Sep 17 00:00:00 2001 From: sriram ramakrishnan Date: Wed, 29 Mar 2023 21:43:55 -0400 Subject: [PATCH 03/10] update yarn.lock Signed-off-by: sriram ramakrishnan --- plugins/analytics-module-ga4/package.json | 2 +- yarn.lock | 40 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/plugins/analytics-module-ga4/package.json b/plugins/analytics-module-ga4/package.json index df77a3a9de..a04225b01e 100644 --- a/plugins/analytics-module-ga4/package.json +++ b/plugins/analytics-module-ga4/package.json @@ -46,7 +46,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", "@testing-library/user-event": "^14.0.0", - "@types/jest": "^26.0.7", + "@types/jest": "^28.1.3", "@types/node": "^16.11.26", "@types/react": "^16.13.1 || ^17.0.0", "cross-fetch": "^3.1.5", diff --git a/yarn.lock b/yarn.lock index 419c2f48d2..1fe44d6f60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4440,6 +4440,39 @@ __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:^" + "@material-ui/core": ^4.12.2 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": 4.0.0-alpha.61 + "@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" @@ -34341,6 +34374,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" From 79e28c047a9e77e820d1684bd018a563031e5e60 Mon Sep 17 00:00:00 2001 From: sriram ramakrishnan Date: Thu, 30 Mar 2023 08:23:18 -0400 Subject: [PATCH 04/10] fix lint issue for window.crypto Signed-off-by: sriram ramakrishnan --- .../src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts index e38a0db1f8..bc974855e1 100644 --- a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts @@ -273,7 +273,7 @@ export class GoogleAnalytics4 implements AnalyticsApi { * @param value value to be hashed */ private async hash(value: string): Promise { - const digest = await crypto.subtle.digest( + const digest = await window.crypto.subtle.digest( 'sha-256', new TextEncoder().encode(value), ); From 3744e679060b82b2403aad5363595822afade9f7 Mon Sep 17 00:00:00 2001 From: sriram ramakrishnan Date: Thu, 30 Mar 2023 08:33:21 -0400 Subject: [PATCH 05/10] fix formatting for README.md Signed-off-by: sriram ramakrishnan --- plugins/analytics-module-ga4/README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/plugins/analytics-module-ga4/README.md b/plugins/analytics-module-ga4/README.md index 7f4ce73b83..4b4ddc860a 100644 --- a/plugins/analytics-module-ga4/README.md +++ b/plugins/analytics-module-ga4/README.md @@ -73,23 +73,21 @@ Additional dimensional data can be captured using custom dimensions, 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_`. + attribute names will be prefixed by `a_`. ```yaml app: analytics: ga4: measurementId: G-0000000-0 - allowedContexts: [ 'pluginId'] + allowedContexts: ['pluginId'] ``` - - ```yaml app: analytics: ga4: - allowedContexts: [ 'pluginId'] + allowedContexts: ['pluginId'] allowedAttributes: ['someEventContextAttr'] ``` @@ -150,21 +148,21 @@ export const apis: AnyApiFactory[] = [ ]; ``` - ### 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. +Please note, content grouping takes 24hrs to show up in the Google Analytics dashboard. ### Debugging and Testing @@ -204,7 +202,7 @@ app: measurementId: G-0000000-0 debug: true testMode: true - allowedContexts: [ 'pluginId'] + allowedContexts: ['pluginId'] ``` [what-is-a-custom-dimension]: https://support.google.com/analytics/answer/2709828 From 11c86543539c7a09f84ff8583539ad2cf652abd2 Mon Sep 17 00:00:00 2001 From: sriram ramakrishnan Date: Thu, 30 Mar 2023 08:47:18 -0400 Subject: [PATCH 06/10] generate api-report Signed-off-by: sriram ramakrishnan --- plugins/analytics-module-ga4/api-report.md | 4 ---- .../apis/implementations/AnalyticsApi/GoogleAnalytics4.ts | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/plugins/analytics-module-ga4/api-report.md b/plugins/analytics-module-ga4/api-report.md index 063c6fde88..77f3094210 100644 --- a/plugins/analytics-module-ga4/api-report.md +++ b/plugins/analytics-module-ga4/api-report.md @@ -5,13 +5,9 @@ ```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; diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts index bc974855e1..399366b804 100644 --- a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts @@ -95,8 +95,8 @@ export class GoogleAnalytics4 implements AnalyticsApi { /** * Instantiate a fully configured GA Analytics API implementation. - * @param config Config object from app config - * @param options options with identityApi and userIdTransform config + * @param config - Config object from app config + * @param options - options with identityApi and userIdTransform config */ static fromConfig( config: Config, @@ -148,7 +148,7 @@ export class GoogleAnalytics4 implements AnalyticsApi { * Primary event capture implementation. Handles core navigate event as a * pageview and the rest as custom events. All custom dimensions/metrics are * applied as they should be (set on pageview, merged object on events). - * @param event AnalyticsEvent type captured + * @param event - AnalyticsEvent type captured */ captureEvent(event: AnalyticsEvent) { const { context, action, subject, value, attributes } = event; From 91afbd743c97e26356ece065b44a8fbe30a82f4c Mon Sep 17 00:00:00 2001 From: sriram ramakrishnan Date: Thu, 30 Mar 2023 10:41:46 -0400 Subject: [PATCH 07/10] Add /dev endpoint Signed-off-by: sriram ramakrishnan --- plugins/analytics-module-ga4/README.md | 10 ++++- .../analytics-module-ga4/dev/Playground.tsx | 26 +++++++++++++ plugins/analytics-module-ga4/dev/index.tsx | 38 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 plugins/analytics-module-ga4/dev/Playground.tsx create mode 100644 plugins/analytics-module-ga4/dev/index.tsx diff --git a/plugins/analytics-module-ga4/README.md b/plugins/analytics-module-ga4/README.md index 4b4ddc860a..bb0a5daae1 100644 --- a/plugins/analytics-module-ga4/README.md +++ b/plugins/analytics-module-ga4/README.md @@ -186,7 +186,15 @@ normally `gitignore`'d but loaded and merged in when Backstage is bootstrapped. 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. +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. 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(); From f1cc70e9f1d283d9ddc7b28015f2ffdc861a3254 Mon Sep 17 00:00:00 2001 From: sramakr Date: Thu, 30 Mar 2023 10:57:33 -0400 Subject: [PATCH 08/10] Delete cron.yml Signed-off-by: sramakr --- .github/workflows/cron.yml | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 .github/workflows/cron.yml diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml deleted file mode 100644 index 3164fd138e..0000000000 --- a/.github/workflows/cron.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Cron -on: - workflow_dispatch: - schedule: - - cron: '*/5 * * * *' - -jobs: - cron: - runs-on: ubuntu-latest - steps: - - uses: backstage/actions/cron@v0.6.3 - with: - app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} - private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }} - installation-id: ${{ secrets.BACKSTAGE_GOALIE_INSTALLATION_ID }} From a6952728ca552479b7b51928231c9a17a8c1aa13 Mon Sep 17 00:00:00 2001 From: sriram ramakrishnan Date: Thu, 6 Apr 2023 10:20:04 -0400 Subject: [PATCH 09/10] Update version to 0.0.0. Remove unused dependencies. Add back github action workflo cron.yml Signed-off-by: sriram ramakrishnan --- .github/workflows/cron.yml | 15 +++++++++++++++ plugins/analytics-module-ga4/package.json | 5 +---- yarn.lock | 3 --- 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/cron.yml diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml new file mode 100644 index 0000000000..3164fd138e --- /dev/null +++ b/.github/workflows/cron.yml @@ -0,0 +1,15 @@ +name: Cron +on: + workflow_dispatch: + schedule: + - cron: '*/5 * * * *' + +jobs: + cron: + runs-on: ubuntu-latest + steps: + - uses: backstage/actions/cron@v0.6.3 + with: + app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} + private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }} + installation-id: ${{ secrets.BACKSTAGE_GOALIE_INSTALLATION_ID }} diff --git a/plugins/analytics-module-ga4/package.json b/plugins/analytics-module-ga4/package.json index a04225b01e..ea2ed16888 100644 --- a/plugins/analytics-module-ga4/package.json +++ b/plugins/analytics-module-ga4/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-analytics-module-ga4", - "version": "0.0.1", + "version": "0.0.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -26,9 +26,6 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/theme": "workspace:^", - "@material-ui/core": "^4.12.2", - "@material-ui/icons": "^4.9.1", - "@material-ui/lab": "4.0.0-alpha.61", "react-ga4": "^2.0.0", "react-use": "^17.2.4" }, diff --git a/yarn.lock b/yarn.lock index 1fe44d6f60..a1fbd3b596 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4452,9 +4452,6 @@ __metadata: "@backstage/dev-utils": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" - "@material-ui/core": ^4.12.2 - "@material-ui/icons": ^4.9.1 - "@material-ui/lab": 4.0.0-alpha.61 "@testing-library/dom": ^8.0.0 "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 From 449b171a4dbc4596e7d66c734a991b7e8064d584 Mon Sep 17 00:00:00 2001 From: sriram ramakrishnan Date: Sat, 8 Apr 2023 10:32:36 -0400 Subject: [PATCH 10/10] update readme Signed-off-by: sriram ramakrishnan --- plugins/analytics-module-ga4/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/analytics-module-ga4/README.md b/plugins/analytics-module-ga4/README.md index bb0a5daae1..d060c9d9b1 100644 --- a/plugins/analytics-module-ga4/README.md +++ b/plugins/analytics-module-ga4/README.md @@ -9,7 +9,7 @@ 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` + `cd packages/app && yarn add @backstage/plugin-analytics-module-ga4` 2. Wire up the API implementation to your App: ```tsx @@ -19,7 +19,7 @@ import { configApiRef, identityApiRef, } from '@backstage/core-plugin-api'; -import { GoogleAnalytics4 } from '@devx-discover/plugin-analytics-ga4'; +import { GoogleAnalytics4 } from '@backstage/plugin-analytics-module-ga4'; export const apis: AnyApiFactory[] = [ // Instantiate and register the GA Analytics API Implementation. @@ -37,7 +37,7 @@ export const apis: AnyApiFactory[] = [ 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: +events to GA. All that's needed is your GA4 measurement ID: ```yaml # app-config.yaml @@ -74,6 +74,8 @@ Additional dimensional data can be captured using custom dimensions, like this: 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: