From b40a0ccc4dc4a795acd13a33ace0c113f4b6f3fa Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 16 Jan 2022 18:34:40 +0100 Subject: [PATCH 1/6] Initial identity-awareness implementation for GA. Signed-off-by: Eric Peterson --- .changeset/analytics-station-eleven.md | 6 + .github/styles/vocab.txt | 1 + plugins/analytics-module-ga/README.md | 74 +++++- plugins/analytics-module-ga/api-report.md | 12 +- plugins/analytics-module-ga/config.d.ts | 19 ++ plugins/analytics-module-ga/package.json | 1 + .../AnalyticsApi/GoogleAnalytics.test.ts | 238 ++++++++++++++++-- .../AnalyticsApi/GoogleAnalytics.ts | 108 +++++++- plugins/analytics-module-ga/src/index.ts | 2 +- plugins/analytics-module-ga/src/plugin.ts | 3 + plugins/analytics-module-ga/src/setupTests.ts | 17 ++ .../src/util/DeferredCapture.ts | 140 +++++++++++ plugins/analytics-module-ga/src/util/index.ts | 17 ++ 13 files changed, 602 insertions(+), 36 deletions(-) create mode 100644 .changeset/analytics-station-eleven.md create mode 100644 plugins/analytics-module-ga/src/util/DeferredCapture.ts create mode 100644 plugins/analytics-module-ga/src/util/index.ts diff --git a/.changeset/analytics-station-eleven.md b/.changeset/analytics-station-eleven.md new file mode 100644 index 0000000000..e1a2366cbe --- /dev/null +++ b/.changeset/analytics-station-eleven.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-analytics-module-ga': patch +--- + +Added the ability to capture and set user IDs from Backstage's `identityApi`. For full instructions on how to +set this up, see [the User ID section of its README](https://github.com/backstage/backstage/tree/master/plugins/analytics-module-ga#user-ids) diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 49c85e7e1c..3abe840368 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -223,6 +223,7 @@ productional Protobuf proxying Proxying +pseudonymized pubsub pygments pymdownx diff --git a/plugins/analytics-module-ga/README.md b/plugins/analytics-module-ga/README.md index a0c4fe517a..1a4bf13aad 100644 --- a/plugins/analytics-module-ga/README.md +++ b/plugins/analytics-module-ga/README.md @@ -14,15 +14,22 @@ This plugin contains no other functionality. ```tsx // packages/app/src/apis.ts -import { analyticsApiRef, configApiRef } from '@backstage/core-plugin-api'; +import { + analyticsApiRef, + configApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga'; export const apis: AnyApiFactory[] = [ // Instantiate and register the GA Analytics API Implementation. createApiFactory({ api: analyticsApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => GoogleAnalytics.fromConfig(configApi), + deps: { configApi: configApiRef, identityApi: identityApiRef }, + factory: ({ configApi, identityApi }) => + GoogleAnalytics.fromConfig(configApi, { + identityApi, + }), }), ]; ``` @@ -92,6 +99,66 @@ app: key: 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.ga.identity` to either `required` or `optional` in your + `app.config.yaml`, like this: + + ```yaml + app: + analytics: + ga: + trackingId: UA-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 custom implementation of the `identityApi` that +resolves a `userEntityRef` of the form `PrivateUser:namespace/YOUR-VALUE`. For +example: + +```typescript +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: analyticsApiRef, + deps: { config: configApiRef, identityApi: identityApiRef }, + factory: ({ identityApi, config }) => { + return new PseudononymizedIdentity(identityApi); + }, + }), +]; + +class PseudononymizedIdentity implements IdentityApi { + constructor(private actualApi: IdentityApi) {} + async getBackstageIdentity(): Promise { + const { email = 'someone' } = await this.actualApi.getProfileInfo(); + const hashedEmail = customHashingFunction(email); + + return { + type: 'user', + userEntityRef: `PrivateUser:default/${hashedEmail}`, + ownershipEntityRefs: [], + }; + } + // ... +} +``` + ### Debugging and Testing In pre-production environments, you may wish to set additional configurations @@ -147,3 +214,4 @@ app: [what-is-a-custom-dimension]: https://support.google.com/analytics/answer/2709828 [configure-custom-dimension]: https://support.google.com/analytics/answer/2709828#configuration +[ga-user-id-view]: https://support.google.com/analytics/answer/3123669 diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md index b0f3b736c3..a396083444 100644 --- a/plugins/analytics-module-ga/api-report.md +++ b/plugins/analytics-module-ga/api-report.md @@ -7,18 +7,20 @@ 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'; -// Warning: (ae-missing-release-tag) "analyticsModuleGA" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const analyticsModuleGA: BackstagePlugin<{}, {}>; -// Warning: (ae-missing-release-tag) "GoogleAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class GoogleAnalytics implements AnalyticsApi { captureEvent(event: AnalyticsEvent): void; - static fromConfig(config: Config): GoogleAnalytics; + static fromConfig( + config: Config, + options?: { + identityApi?: IdentityApi; + }, + ): GoogleAnalytics; } // (No @packageDocumentation comment for this package) diff --git a/plugins/analytics-module-ga/config.d.ts b/plugins/analytics-module-ga/config.d.ts index ac534daae8..b91f5812c1 100644 --- a/plugins/analytics-module-ga/config.d.ts +++ b/plugins/analytics-module-ga/config.d.ts @@ -34,6 +34,25 @@ export interface Config { */ scriptSrc?: string; + /** + * Controls how the identityApi is used when sending data to GA: + * + * - `disabled`: (Default) Explicitly prevents a user's identity from + * being used when capturing events in GA. + * - `optional`: Pageviews and hits are forwarded to GA as they happen + * and only include user identity metadata once known. Guarantees + * that hits are captured for all sessions, even if no sign in + * occurs, but may result in dropped hits in User ID views. + * - `required`: All pageviews and hits are deferred until an identity + * is known. Guarantees that all data sent to GA correlates to a user + * identity, but prevents GA from receiving events for sessions in + * which a user does not sign in. An `identityApi` instance must be + * passed during instantiation when set to this value. + * + * @visibility frontend + */ + identity?: 'disabled' | 'optional' | 'required'; + /** * Whether or not to log analytics debug statements to the console. * Defaults to false. diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 255a0cae8e..264c46410f 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -21,6 +21,7 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/catalog-model": "^0.9.10", "@backstage/config": "^0.1.13", "@backstage/core-components": "^0.8.6", "@backstage/core-plugin-api": "^0.6.0", diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts index cbcf95cb55..57a4d4f4c4 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -15,10 +15,17 @@ */ import { ConfigReader } from '@backstage/config'; +import { IdentityApi } from '@backstage/core-plugin-api'; import ReactGA from 'react-ga'; import { GoogleAnalytics } from './GoogleAnalytics'; describe('GoogleAnalytics', () => { + const context = { + extension: 'App', + pluginId: 'some-plugin', + routeRef: 'unknown', + releaseNum: 1337, + }; const trackingId = 'UA-000000-0'; const basicValidConfig = new ConfigReader({ app: { analytics: { ga: { trackingId, testMode: true } } }, @@ -50,12 +57,6 @@ describe('GoogleAnalytics', () => { }); describe('integration', () => { - const context = { - extension: 'App', - pluginId: 'some-plugin', - routeRef: 'unknown', - releaseNum: 1337, - }; const advancedConfig = new ConfigReader({ app: { analytics: { @@ -141,20 +142,13 @@ describe('GoogleAnalytics', () => { context, }); - // Expect a set command first. - const [setCommand, setData] = ReactGA.testModeAPI.calls[1]; - expect(setCommand).toBe('set'); - expect(setData).toMatchObject({ - dimension1: context.pluginId, - metric1: context.releaseNum, - }); - - // Followed by a send command. - const [sendCommand, sendData] = ReactGA.testModeAPI.calls[2]; - expect(sendCommand).toBe('send'); - expect(sendData).toMatchObject({ + const [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ hitType: 'pageview', page: '/a-page', + dimension1: context.pluginId, + metric1: context.releaseNum, }); }); @@ -208,4 +202,212 @@ describe('GoogleAnalytics', () => { }); }); }); + + 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 = GoogleAnalytics.fromConfig(basicValidConfig, { identityApi }); + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + // Wait for any/all promises involved to settle. + await new Promise(resolve => setImmediate(resolve)); + + // A pageview should have been fired immediately. + const [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + + // There should not have been a UserID set. + expect(ReactGA.testModeAPI.calls).toHaveLength(2); + }); + + it('sets hashed userId when identityApi is provided', async () => { + // Instantiate with identityApi and identity set to optional + const optionalConfig = new ConfigReader({ + app: { + analytics: { + ga: { trackingId, testMode: true, identity: 'optional' }, + }, + }, + }); + const api = GoogleAnalytics.fromConfig(optionalConfig, { identityApi }); + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + // Wait for any/all promises involved to settle. + await new Promise(resolve => setImmediate(resolve)); + + // A pageview should have been fired immediately. + const [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + + // User ID should have been set after the pageview. + const [setCommand, setData] = ReactGA.testModeAPI.calls[2]; + expect(setCommand).toBe('set'); + expect(setData).toMatchObject({ + // String indicating userEntityRef went through expected hashing. + userId: '557365723a64656661756c742f736f6d656f6e65', + }); + }); + + it('sets pre-hashed userId when PrivateUser entity ref is provided', async () => { + (identityApi.getBackstageIdentity as jest.Mock).mockResolvedValueOnce({ + userEntityRef: 'PrivateUser:hashed/s0m3hash3dvalu3', + }); + const optionalConfig = new ConfigReader({ + app: { + analytics: { + ga: { trackingId, testMode: true, identity: 'optional' }, + }, + }, + }); + const api = GoogleAnalytics.fromConfig(optionalConfig, { identityApi }); + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + // Wait for any/all promises involved to settle. + await new Promise(resolve => setImmediate(resolve)); + + // User ID should have been set after the pageview. + const [setCommand, setData] = ReactGA.testModeAPI.calls[2]; + expect(setCommand).toBe('set'); + expect(setData).toMatchObject({ + userId: 's0m3hash3dvalu3', + }); + }); + + it('does not set userId when identityApi is provided and ga.identity is explicitly disabled', async () => { + // Instantiate with identityApi and identity explicitly disabled. + const disabledConfig = new ConfigReader({ + app: { + analytics: { + ga: { trackingId, testMode: true, identity: 'disabled' }, + }, + }, + }); + const api = GoogleAnalytics.fromConfig(disabledConfig, { identityApi }); + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + // Wait for any/all promises involved to settle. + await new Promise(resolve => setImmediate(resolve)); + + // A pageview should have been fired immediately. + const [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + + // There should not have been a UserID set. + expect(ReactGA.testModeAPI.calls).toHaveLength(2); + }); + + it('throws error when ga.identity is required but no identityApi is provided', async () => { + // Instantiate without identityApi and identity explicitly disabled. + const requiredConfig = new ConfigReader({ + app: { + analytics: { + ga: { trackingId, testMode: true, identity: 'required' }, + }, + }, + }); + + expect(() => GoogleAnalytics.fromConfig(requiredConfig)).toThrow(); + }); + + it('defers event capture when ga.identity is required', async () => { + // Instantiate with identityApi and identity explicitly required. + const requiredConfig = new ConfigReader({ + app: { + analytics: { + ga: { trackingId, testMode: true, identity: 'required' }, + }, + }, + }); + const api = GoogleAnalytics.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 => setImmediate(resolve)); + + // User ID should have been set first. + const [setCommand, setData] = ReactGA.testModeAPI.calls[1]; + expect(setCommand).toBe('set'); + expect(setData).toMatchObject({ + // String indicating userEntityRef went through expected hashing. + userId: '557365723a64656661756c742f736f6d656f6e65', + }); + + // Then a pageview should have been fired with a queue time. + const [pageCommand, pageData] = ReactGA.testModeAPI.calls[2]; + expect(pageCommand).toBe('send'); + expect(pageData).toMatchObject({ + hitType: 'pageview', + page: '/', + queueTime: expect.any(Number), + }); + + // Then an event should have been fired with a queue time. + const [eventCommand, eventData] = ReactGA.testModeAPI.calls[3]; + expect(eventCommand).toBe('send'); + expect(eventData).toMatchObject({ + hitType: 'event', + queueTime: expect.any(Number), + }); + + // And subsequent hits should not have a queue time. + api.captureEvent({ + action: 'navigate', + subject: '/page-2', + context, + }); + + const [lastCommand, lastData] = ReactGA.testModeAPI.calls[4]; + expect(lastCommand).toBe('send'); + expect(lastData).toMatchObject({ + hitType: 'pageview', + page: '/page-2', + }); + expect(lastData.queueTime).toBeUndefined(); + }); + }); }); diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index 28202607be..6e5a12df4a 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -15,13 +15,16 @@ */ import ReactGA from 'react-ga'; +import { parseEntityRef } from '@backstage/catalog-model'; import { AnalyticsApi, AnalyticsContextValue, AnalyticsEventAttributes, AnalyticsEvent, + IdentityApi, } from '@backstage/core-plugin-api'; import { Config } from '@backstage/config'; +import { DeferredCapture } from '../../../util'; type CustomDimensionOrMetricConfig = { type: 'dimension' | 'metric'; @@ -32,21 +35,33 @@ type CustomDimensionOrMetricConfig = { /** * Google Analytics API provider for the Backstage Analytics API. + * @public */ export class GoogleAnalytics implements AnalyticsApi { private readonly cdmConfig: CustomDimensionOrMetricConfig[]; + private readonly capture: DeferredCapture; /** * Instantiate the implementation and initialize ReactGA. */ private constructor(options: { + identityApi?: IdentityApi; cdmConfig: CustomDimensionOrMetricConfig[]; + identity: string; trackingId: string; scriptSrc?: string; testMode: boolean; debug: boolean; }) { - const { cdmConfig, trackingId, scriptSrc, testMode, debug } = options; + const { + cdmConfig, + identity, + trackingId, + identityApi, + scriptSrc, + testMode, + debug, + } = options; this.cdmConfig = cdmConfig; @@ -57,15 +72,28 @@ export class GoogleAnalytics implements AnalyticsApi { gaAddress: scriptSrc, titleCase: false, }); + + // If identity is required, defer event capture until identity is known. + this.capture = new DeferredCapture({ defer: identity === 'required' }); + + // Capture user only when explicitly enabled and provided. + if (identity !== 'disabled' && identityApi) { + this.setUserFrom(identityApi); + } } /** * Instantiate a fully configured GA Analytics API implementation. */ - static fromConfig(config: Config) { + static fromConfig( + config: Config, + options: { identityApi?: IdentityApi } = {}, + ) { // Get all necessary configuration. const trackingId = config.getString('app.analytics.ga.trackingId'); const scriptSrc = config.getOptionalString('app.analytics.ga.scriptSrc'); + const identity = + config.getOptionalString('app.analytics.ga.identity') || 'disabled'; const debug = config.getOptionalBoolean('app.analytics.ga.debug') ?? false; const testMode = config.getOptionalBoolean('app.analytics.ga.testMode') ?? false; @@ -83,8 +111,16 @@ export class GoogleAnalytics implements AnalyticsApi { }; }) ?? []; + if (identity === 'required' && !options.identityApi) { + throw new Error( + 'Invalid config: identity API must be provided to deps when ga.identity is required', + ); + } + // Return an implementation instance. return new GoogleAnalytics({ + ...options, + identity, trackingId, scriptSrc, cdmConfig, @@ -103,16 +139,11 @@ export class GoogleAnalytics implements AnalyticsApi { const customMetadata = this.getCustomDimensionMetrics(context, attributes); if (action === 'navigate' && context.extension === 'App') { - // Set any/all custom dimensions. - if (Object.keys(customMetadata).length) { - ReactGA.set(customMetadata); - } - - ReactGA.pageview(subject); + this.capture.pageview(subject, customMetadata); return; } - ReactGA.event({ + this.capture.event({ category: context.extension || 'App', action, label: subject, @@ -151,4 +182,63 @@ export class GoogleAnalytics implements AnalyticsApi { return customDimensionsMetrics; } + + /** + * 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 dummy Identity API + * implementation which returns a `userEntityRef` whose kind is the literal + * string `PrivateUser`, whose namespace is anything (it will be ignored) and + * whose name is the pre-hashed ID value. + * + * - With value `PrivateUser:default/a0n3b4n3`, userId becomes `a0n3b4n3` + * - With `PrivateUser:xyz/a0n3b4n3`, userId is `a0n3b4n3` + * + * Note: this feature requires that an integrator has set up a Google + * Analytics User ID view in the property used to track Backstage. + */ + 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({ userId }); + + // Notify the deferred capture mechanism that it may proceed. + this.capture.setReady(); + } + + /** + * Returns a PII-free user ID for use in Google Analytics. + */ + private getPrivateUserId(userEntityRef: string): Promise { + const entity = parseEntityRef(userEntityRef); + + // Mechanism allowing integrators to provide their own hashed values. + if (entity.kind === 'PrivateUser') { + return Promise.resolve(entity.name); + } + + return this.hash(userEntityRef); + } + + /** + * Simple hash function; relies on web cryptography + the sha-256 algorithm. + */ + 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-ga/src/index.ts b/plugins/analytics-module-ga/src/index.ts index 17a3213c6d..fca8d91658 100644 --- a/plugins/analytics-module-ga/src/index.ts +++ b/plugins/analytics-module-ga/src/index.ts @@ -15,4 +15,4 @@ */ export { analyticsModuleGA } from './plugin'; -export { GoogleAnalytics } from './apis/implementations/AnalyticsApi'; +export * from './apis/implementations/AnalyticsApi'; diff --git a/plugins/analytics-module-ga/src/plugin.ts b/plugins/analytics-module-ga/src/plugin.ts index d63ae6616f..d2b15f370e 100644 --- a/plugins/analytics-module-ga/src/plugin.ts +++ b/plugins/analytics-module-ga/src/plugin.ts @@ -15,6 +15,9 @@ */ import { createPlugin } from '@backstage/core-plugin-api'; +/** + * @public + */ export const analyticsModuleGA = createPlugin({ id: 'analytics-provider-ga', }); diff --git a/plugins/analytics-module-ga/src/setupTests.ts b/plugins/analytics-module-ga/src/setupTests.ts index fc6dbd98f8..4ed20ac097 100644 --- a/plugins/analytics-module-ga/src/setupTests.ts +++ b/plugins/analytics-module-ga/src/setupTests.ts @@ -15,3 +15,20 @@ */ 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-ga/src/util/DeferredCapture.ts b/plugins/analytics-module-ga/src/util/DeferredCapture.ts new file mode 100644 index 0000000000..35269ce898 --- /dev/null +++ b/plugins/analytics-module-ga/src/util/DeferredCapture.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import ReactGA from 'react-ga'; + +type Hit = { + timestamp: number; + data: { + hitType: 'pageview' | 'event'; + [x: string]: any; + }; +}; + +/** + * A wrapper around ReactGA that can optionally handle latent capture logic. + * + * - When defer is `false`, event data is sent directly to GA. + * - When defer is `true`, event data is queued (with a timestamp), so that it + * can be sent to GA once externally indicated to be ready. This relies on + * the `qt` or `queueTime` parameter of the Measurement Protocol. + * + * @see https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#qt + */ +export class DeferredCapture { + /** + * Queue of deferred hits to be processed when ready. + */ + private queue: Hit[] = []; + + /** + * Marker indicating when it's okay to revert to synchronous capture. + */ + private doneDeferring = false; + + /** + * Whether or not deferred capture is desired. + */ + private defer: boolean; + + /** + * Holds a reference to the internal promise's resolver. When called, it will + * begin processing hits in the queue. + */ + private isReady: () => void = () => {}; + + constructor({ defer = false }: { defer: boolean }) { + this.defer = defer; + + // Set up a readiness promise that, when resolved from the outside, goes + // through all queued hits and sends them. + new Promise(resolve => { + this.isReady = resolve; + }).then(() => { + this.queue.forEach(this.sendDeferred); + }); + } + + /** + * Indicates that deferred capture may now proceed. + */ + setReady() { + if (!this.doneDeferring) { + this.isReady(); + this.doneDeferring = true; + } + } + + /** + * Either forwards the pageview directly to GA, or (if configured) enqueues + * the pageview hit to be captured when ready. + */ + pageview(path: string, metadata: ReactGA.FieldsObject = {}) { + if (this.shouldDefer()) { + this.queue.push({ + timestamp: Date.now(), + data: { + hitType: 'pageview', + page: path, + ...metadata, + }, + }); + return; + } + + ReactGA.send({ + hitType: 'pageview', + page: path, + ...metadata, + }); + } + + /** + * Either forwards the event directly to GA, or (if configured) enqueues the + * event hit to be captured when ready. + */ + event(eventDetails: ReactGA.EventArgs) { + if (this.shouldDefer()) { + this.queue.push({ + timestamp: Date.now(), + data: { + ...eventDetails, + hitType: 'event', + }, + }); + return; + } + + ReactGA.event(eventDetails); + } + + /** + * Only defer if configured and if we are still not ready. + */ + private shouldDefer() { + return this.defer && !this.doneDeferring; + } + + /** + * Sends a given hit to GA, decorated with the correct queue time. + */ + private sendDeferred(hit: Hit) { + // Send the hit with the appropriate queue time (`qt`). + ReactGA.send({ + ...hit.data, + queueTime: Date.now() - hit.timestamp, + }); + } +} diff --git a/plugins/analytics-module-ga/src/util/index.ts b/plugins/analytics-module-ga/src/util/index.ts new file mode 100644 index 0000000000..3b7a90c808 --- /dev/null +++ b/plugins/analytics-module-ga/src/util/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DeferredCapture } from './DeferredCapture'; From 35d1d675b57b624fa51e5b04a82161b747059db5 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Jan 2022 18:15:50 +0100 Subject: [PATCH 2/6] Document convention for supporting user identity in Analytics. Signed-off-by: Eric Peterson --- docs/plugins/analytics.md | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index 8a58677af6..d5fd02a46d 100644 --- a/docs/plugins/analytics.md +++ b/docs/plugins/analytics.md @@ -141,6 +141,63 @@ By convention, such packages should be named `@backstage/analytics-module-[name]`, and any configuration should be keyed under `app.analytics.[name]`. +### Handling User Identity + +If the analytics platform you are integrating with has a first-class concept of +user identity, you can (optionally) choose to support this by the following this +convention: + +- Allow your implementation to be instantiated with the `identityApi` as one of + its options in a `fromConfig` static method. +- Use the `userEntityRef` resolved by `identityApi`'s `getBackstageIdentity()` + method as the basis for the user ID you send to your analytics platform. + +For example: + +```typescript +import { + AnalyticsApi, + analyticsApiRef, + AnyApiFactory, + configApiRef, + createApiFactory, + identityApiRef, + IdentityApi, +} from '@backstage/core-plugin-api'; + +// Implementation that optionally initializes with a userId. +class AcmeAnalytics implements AnalyticsApi { + private constructor(accountId: number, identityApi?: IdentityApi) { + if (identityApi) { + identityApi.getBackstageIdentity().then(identity => { + AcmeAnalytics.init(accountId, { + userId: identity.userEntityRef, + }); + }); + } else { + AcmeAnalytics.init(accountId); + } + } + + static fromConfig(config, options) { + const accountId = config.getString('app.analytics.acme.id'); + return new AcmeAnalytics(accountId, options.identityApi); + } +} + +// Your implementation should be instantiated like this: +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: analyticsApiRef, + deps: { configApi: configApiRef, identityApi: identityApiRef }, + factory: ({ configApi, identityApi }) => + AcmeAnalytics.fromConfig(configApi, { + identityApi, + }), + }), +]; +``` + ## Capturing Events To instrument an event in a component, start by retrieving an analytics tracker From 919a77845d4fcda70b3f3b84458e9f15dbb712a7 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 30 Jan 2022 18:51:18 +0100 Subject: [PATCH 3/6] Less magical custom hash mechanism. Signed-off-by: Eric Peterson --- plugins/analytics-module-ga/README.md | 41 +++++++++---------- plugins/analytics-module-ga/api-report.md | 3 ++ plugins/analytics-module-ga/package.json | 1 - .../AnalyticsApi/GoogleAnalytics.test.ts | 12 +++--- .../AnalyticsApi/GoogleAnalytics.ts | 23 +++++++---- 5 files changed, 45 insertions(+), 35 deletions(-) diff --git a/plugins/analytics-module-ga/README.md b/plugins/analytics-module-ga/README.md index 1a4bf13aad..92cfd7e9ae 100644 --- a/plugins/analytics-module-ga/README.md +++ b/plugins/analytics-module-ga/README.md @@ -128,35 +128,32 @@ enable this... 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 custom implementation of the `identityApi` that -resolves a `userEntityRef` of the form `PrivateUser:namespace/YOUR-VALUE`. For -example: +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: { config: configApiRef, identityApi: identityApiRef }, - factory: ({ identityApi, config }) => { - return new PseudononymizedIdentity(identityApi); - }, + deps: { configApi: configApiRef, identityApi: identityApiRef }, + factory: ({ configApi, identityApi }) => + GoogleAnalytics.fromConfig(configApi, { + identityApi, + userIdTransform: async (userEntityRef: string): Promise => { + return customHashingFunction(userEntityRef); + }, + }), }), ]; - -class PseudononymizedIdentity implements IdentityApi { - constructor(private actualApi: IdentityApi) {} - async getBackstageIdentity(): Promise { - const { email = 'someone' } = await this.actualApi.getProfileInfo(); - const hashedEmail = customHashingFunction(email); - - return { - type: 'user', - userEntityRef: `PrivateUser:default/${hashedEmail}`, - ownershipEntityRefs: [], - }; - } - // ... -} ``` ### Debugging and Testing diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md index a396083444..45bb84a421 100644 --- a/plugins/analytics-module-ga/api-report.md +++ b/plugins/analytics-module-ga/api-report.md @@ -19,6 +19,9 @@ export class GoogleAnalytics implements AnalyticsApi { config: Config, options?: { identityApi?: IdentityApi; + userIdTransform?: + | 'sha-256' + | ((userEntityRef: string) => Promise); }, ): GoogleAnalytics; } diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 264c46410f..255a0cae8e 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -21,7 +21,6 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.10", "@backstage/config": "^0.1.13", "@backstage/core-components": "^0.8.6", "@backstage/core-plugin-api": "^0.6.0", diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts index 57a4d4f4c4..a4f7195fec 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -270,10 +270,8 @@ describe('GoogleAnalytics', () => { }); }); - it('sets pre-hashed userId when PrivateUser entity ref is provided', async () => { - (identityApi.getBackstageIdentity as jest.Mock).mockResolvedValueOnce({ - userEntityRef: 'PrivateUser:hashed/s0m3hash3dvalu3', - }); + it('set custom-hashed userId when userIdTransform is provided', async () => { + const userIdTransform = jest.fn().mockResolvedValue('s0m3hash3dvalu3'); const optionalConfig = new ConfigReader({ app: { analytics: { @@ -281,7 +279,10 @@ describe('GoogleAnalytics', () => { }, }, }); - const api = GoogleAnalytics.fromConfig(optionalConfig, { identityApi }); + const api = GoogleAnalytics.fromConfig(optionalConfig, { + identityApi, + userIdTransform, + }); api.captureEvent({ action: 'navigate', subject: '/', @@ -297,6 +298,7 @@ describe('GoogleAnalytics', () => { expect(setData).toMatchObject({ userId: 's0m3hash3dvalu3', }); + expect(userIdTransform).toHaveBeenCalledWith('User:default/someone'); }); it('does not set userId when identityApi is provided and ga.identity is explicitly disabled', async () => { diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index 6e5a12df4a..bdbdaebee7 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -15,7 +15,6 @@ */ import ReactGA from 'react-ga'; -import { parseEntityRef } from '@backstage/catalog-model'; import { AnalyticsApi, AnalyticsContextValue, @@ -39,6 +38,7 @@ type CustomDimensionOrMetricConfig = { */ export class GoogleAnalytics implements AnalyticsApi { private readonly cdmConfig: CustomDimensionOrMetricConfig[]; + private customUserIdTransform?: (userEntityRef: string) => Promise; private readonly capture: DeferredCapture; /** @@ -46,6 +46,7 @@ export class GoogleAnalytics implements AnalyticsApi { */ private constructor(options: { identityApi?: IdentityApi; + userIdTransform?: 'sha-256' | ((userEntityRef: string) => Promise); cdmConfig: CustomDimensionOrMetricConfig[]; identity: string; trackingId: string; @@ -58,6 +59,7 @@ export class GoogleAnalytics implements AnalyticsApi { identity, trackingId, identityApi, + userIdTransform = 'sha-256', scriptSrc, testMode, debug, @@ -76,6 +78,10 @@ export class GoogleAnalytics implements AnalyticsApi { // 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' && identityApi) { this.setUserFrom(identityApi); @@ -87,7 +93,12 @@ export class GoogleAnalytics implements AnalyticsApi { */ static fromConfig( config: Config, - options: { identityApi?: IdentityApi } = {}, + options: { + identityApi?: IdentityApi; + userIdTransform?: + | 'sha-256' + | ((userEntityRef: string) => Promise); + } = {}, ) { // Get all necessary configuration. const trackingId = config.getString('app.analytics.ga.trackingId'); @@ -220,11 +231,9 @@ export class GoogleAnalytics implements AnalyticsApi { * Returns a PII-free user ID for use in Google Analytics. */ private getPrivateUserId(userEntityRef: string): Promise { - const entity = parseEntityRef(userEntityRef); - - // Mechanism allowing integrators to provide their own hashed values. - if (entity.kind === 'PrivateUser') { - return Promise.resolve(entity.name); + // Allow integrators to provide their own hashing transformer. + if (this.customUserIdTransform) { + return this.customUserIdTransform(userEntityRef); } return this.hash(userEntityRef); From b4b678eb5af55d33094ba23630c6c63a587d5ab4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 30 Jan 2022 18:51:36 +0100 Subject: [PATCH 4/6] Simpler queue mechanism in DeferredCapture. Signed-off-by: Eric Peterson --- .../src/util/DeferredCapture.ts | 48 ++++--------------- 1 file changed, 9 insertions(+), 39 deletions(-) diff --git a/plugins/analytics-module-ga/src/util/DeferredCapture.ts b/plugins/analytics-module-ga/src/util/DeferredCapture.ts index 35269ce898..38afecd74d 100644 --- a/plugins/analytics-module-ga/src/util/DeferredCapture.ts +++ b/plugins/analytics-module-ga/src/util/DeferredCapture.ts @@ -35,45 +35,22 @@ type Hit = { */ export class DeferredCapture { /** - * Queue of deferred hits to be processed when ready. + * Queue of deferred hits to be processed when ready. When undefined, hits + * can safely be sent without delay. */ - private queue: Hit[] = []; - - /** - * Marker indicating when it's okay to revert to synchronous capture. - */ - private doneDeferring = false; - - /** - * Whether or not deferred capture is desired. - */ - private defer: boolean; - - /** - * Holds a reference to the internal promise's resolver. When called, it will - * begin processing hits in the queue. - */ - private isReady: () => void = () => {}; + private queue: Hit[] | undefined; constructor({ defer = false }: { defer: boolean }) { - this.defer = defer; - - // Set up a readiness promise that, when resolved from the outside, goes - // through all queued hits and sends them. - new Promise(resolve => { - this.isReady = resolve; - }).then(() => { - this.queue.forEach(this.sendDeferred); - }); + this.queue = defer ? [] : undefined; } /** * Indicates that deferred capture may now proceed. */ setReady() { - if (!this.doneDeferring) { - this.isReady(); - this.doneDeferring = true; + if (this.queue) { + this.queue.forEach(this.sendDeferred); + this.queue = undefined; } } @@ -82,7 +59,7 @@ export class DeferredCapture { * the pageview hit to be captured when ready. */ pageview(path: string, metadata: ReactGA.FieldsObject = {}) { - if (this.shouldDefer()) { + if (this.queue) { this.queue.push({ timestamp: Date.now(), data: { @@ -106,7 +83,7 @@ export class DeferredCapture { * event hit to be captured when ready. */ event(eventDetails: ReactGA.EventArgs) { - if (this.shouldDefer()) { + if (this.queue) { this.queue.push({ timestamp: Date.now(), data: { @@ -120,13 +97,6 @@ export class DeferredCapture { ReactGA.event(eventDetails); } - /** - * Only defer if configured and if we are still not ready. - */ - private shouldDefer() { - return this.defer && !this.doneDeferring; - } - /** * Sends a given hit to GA, decorated with the correct queue time. */ From e9ca4ff2bd25c009bbe9b35f5319141b29cab1e2 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 30 Jan 2022 18:58:03 +0100 Subject: [PATCH 5/6] Deprecate useless plugin export. Signed-off-by: Eric Peterson --- plugins/analytics-module-ga/api-report.md | 2 +- plugins/analytics-module-ga/src/plugin.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md index 45bb84a421..9a359f81e9 100644 --- a/plugins/analytics-module-ga/api-report.md +++ b/plugins/analytics-module-ga/api-report.md @@ -9,7 +9,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { Config } from '@backstage/config'; import { IdentityApi } from '@backstage/core-plugin-api'; -// @public (undocumented) +// @public @deprecated (undocumented) export const analyticsModuleGA: BackstagePlugin<{}, {}>; // @public diff --git a/plugins/analytics-module-ga/src/plugin.ts b/plugins/analytics-module-ga/src/plugin.ts index d2b15f370e..4eec63aa2e 100644 --- a/plugins/analytics-module-ga/src/plugin.ts +++ b/plugins/analytics-module-ga/src/plugin.ts @@ -16,6 +16,9 @@ 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 analyticsModuleGA = createPlugin({ From e8df3310781237c965d78a88eb2b0285f12ae8a8 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 31 Jan 2022 18:39:45 +0100 Subject: [PATCH 6/6] Update docs to match reality. Signed-off-by: Eric Peterson --- .../AnalyticsApi/GoogleAnalytics.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index bdbdaebee7..ad7205983c 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -203,13 +203,10 @@ export class GoogleAnalytics implements AnalyticsApi { * - 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 dummy Identity API - * implementation which returns a `userEntityRef` whose kind is the literal - * string `PrivateUser`, whose namespace is anything (it will be ignored) and - * whose name is the pre-hashed ID value. - * - * - With value `PrivateUser:default/a0n3b4n3`, userId becomes `a0n3b4n3` - * - With `PrivateUser:xyz/a0n3b4n3`, userId is `a0n3b4n3` + * 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. @@ -228,7 +225,8 @@ export class GoogleAnalytics implements AnalyticsApi { } /** - * Returns a PII-free user ID for use in Google Analytics. + * Returns a PII-free (according to Google's terms of service) user ID for + * use in Google Analytics. */ private getPrivateUserId(userEntityRef: string): Promise { // Allow integrators to provide their own hashing transformer.