-
Notifications
You must be signed in to change notification settings - Fork 140
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refactor consent wrapper to add analytics service (#997)
- Loading branch information
Showing
18 changed files
with
383 additions
and
134 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@segment/analytics-consent-tools': patch | ||
--- | ||
|
||
Refactor internally to add AnalyticsService |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
178 changes: 178 additions & 0 deletions
178
packages/consent/consent-tools/src/domain/analytics/__tests__/analytics-service.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
import { AnalyticsService, getInitializedAnalytics } from '../analytics-service' | ||
import { analyticsMock } from '../../../test-helpers/mocks' | ||
import { ValidationError } from '../../validation/validation-error' | ||
|
||
describe(AnalyticsService, () => { | ||
let analyticsService: AnalyticsService | ||
|
||
beforeEach(() => { | ||
analyticsService = new AnalyticsService(analyticsMock) | ||
}) | ||
|
||
describe('constructor', () => { | ||
it('should throw an error if the analytics instance is not valid', () => { | ||
// @ts-ignore | ||
expect(() => new AnalyticsService(undefined)).toThrowError( | ||
ValidationError | ||
) | ||
}) | ||
}) | ||
|
||
describe('cdnSettings', () => { | ||
it('should be a promise', async () => { | ||
expect(analyticsMock.on).toBeCalledTimes(1) | ||
expect(analyticsMock.on.mock.lastCall[0]).toBe('initialize') | ||
analyticsMock.on.mock.lastCall[1]({ integrations: {} }) | ||
|
||
await expect(analyticsService['cdnSettings']).resolves.toEqual({ | ||
integrations: {}, | ||
}) | ||
}) | ||
}) | ||
|
||
describe('loadNormally', () => { | ||
it('loads normally', () => { | ||
analyticsService = new AnalyticsService(analyticsMock) | ||
analyticsService.loadNormally('foo') | ||
expect(analyticsMock.load).toBeCalled() | ||
}) | ||
|
||
it('uses the correct value of *this*', () => { | ||
let that: any | ||
function fn(this: any) { | ||
// eslint-disable-next-line @typescript-eslint/no-this-alias | ||
that = this | ||
} | ||
const _analyticsMock = { | ||
...analyticsMock, | ||
load: fn, | ||
name: 'some instance', | ||
} | ||
analyticsService = new AnalyticsService(_analyticsMock) | ||
analyticsService.loadNormally('foo') | ||
expect(that.name).toEqual('some instance') | ||
}) | ||
|
||
it('will always call the original .load method', () => { | ||
const ogLoad = jest.fn() | ||
analyticsService = new AnalyticsService({ | ||
...analyticsMock, | ||
load: ogLoad, | ||
}) | ||
const replaceLoadMethod = jest.fn() | ||
analyticsService.replaceLoadMethod(replaceLoadMethod) | ||
analyticsService.loadNormally('foo') | ||
expect(ogLoad).toHaveBeenCalled() | ||
analyticsService.replaceLoadMethod(replaceLoadMethod) | ||
analyticsService.loadNormally('foo') | ||
expect(replaceLoadMethod).not.toBeCalled() | ||
}) | ||
}) | ||
|
||
describe('replaceLoadMethod', () => { | ||
it('should replace the load method with the provided function', () => { | ||
const replaceLoadMethod = jest.fn() | ||
analyticsService.replaceLoadMethod(replaceLoadMethod) | ||
expect(analyticsService['analytics'].load).toBe(replaceLoadMethod) | ||
}) | ||
}) | ||
|
||
describe('configureConsentStampingMiddleware', () => { | ||
// More tests are in create-wrapper.test.ts... should probably move the integration-y tests here | ||
it('should add the middleware to the analytics instance', () => { | ||
analyticsService.configureConsentStampingMiddleware({ | ||
getCategories: () => ({ | ||
C0001: true, | ||
}), | ||
}) | ||
expect(analyticsMock.addSourceMiddleware).toBeCalledTimes(1) | ||
expect(analyticsMock.addSourceMiddleware).toBeCalledWith( | ||
expect.any(Function) | ||
) | ||
}) | ||
|
||
it('should stamp consent', async () => { | ||
const payload = { | ||
obj: { | ||
context: {}, | ||
}, | ||
} | ||
analyticsService.configureConsentStampingMiddleware({ | ||
getCategories: () => ({ | ||
C0001: true, | ||
C0002: false, | ||
}), | ||
}) | ||
await analyticsMock.addSourceMiddleware.mock.lastCall[0]({ | ||
payload, | ||
next: jest.fn(), | ||
}) | ||
expect((payload.obj.context as any).consent).toEqual({ | ||
categoryPreferences: { | ||
C0001: true, | ||
C0002: false, | ||
}, | ||
}) | ||
}) | ||
}) | ||
|
||
describe('consentChange', () => { | ||
it('should call the track method with the expected arguments', () => { | ||
const mockCategories = { C0001: true, C0002: false } | ||
analyticsService.consentChange(mockCategories) | ||
expect(analyticsMock.track).toBeCalledWith( | ||
'Segment Consent Preference', | ||
undefined, | ||
{ consent: { categoryPreferences: mockCategories } } | ||
) | ||
}) | ||
|
||
it('should log an error if the categories are invalid', () => { | ||
const mockCategories = { invalid: 'nope' } as any | ||
console.error = jest.fn() | ||
analyticsService.consentChange(mockCategories) | ||
expect(console.error).toBeCalledTimes(1) | ||
expect(console.error).toBeCalledWith(expect.any(ValidationError)) | ||
}) | ||
}) | ||
}) | ||
|
||
describe(getInitializedAnalytics, () => { | ||
beforeEach(() => { | ||
delete (window as any).analytics | ||
delete (window as any).foo | ||
}) | ||
|
||
it('should return the window.analytics object if the snippet user passes a stale reference', () => { | ||
;(window as any).analytics = { initialized: true } | ||
const analytics = [] as any | ||
expect(getInitializedAnalytics(analytics)).toEqual( | ||
(window as any).analytics | ||
) | ||
}) | ||
|
||
it('should return the correct global analytics instance if the user has set a globalAnalyticsKey', () => { | ||
;(window as any).foo = { initialized: true } | ||
const analytics = [] as any | ||
analytics._loadOptions = { globalAnalyticsKey: 'foo' } | ||
expect(getInitializedAnalytics(analytics)).toEqual((window as any).foo) | ||
}) | ||
|
||
it('should return the buffered instance if analytics is not initialized', () => { | ||
const analytics = [] as any | ||
const globalAnalytics = { initialized: false } | ||
// @ts-ignore | ||
window['analytics'] = globalAnalytics | ||
expect(getInitializedAnalytics(analytics)).toEqual(analytics) | ||
}) | ||
it('invariant: should not throw if global analytics is undefined', () => { | ||
;(window as any).analytics = undefined | ||
const analytics = [] as any | ||
expect(getInitializedAnalytics(analytics)).toBe(analytics) | ||
}) | ||
|
||
it('should return the analytics object if it is not an array', () => { | ||
const analytics = { initialized: false } as any | ||
expect(getInitializedAnalytics(analytics)).toBe(analytics) | ||
}) | ||
}) |
126 changes: 126 additions & 0 deletions
126
packages/consent/consent-tools/src/domain/analytics/analytics-service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
import { | ||
AnyAnalytics, | ||
Categories, | ||
CDNSettings, | ||
CreateWrapperSettings, | ||
MaybeInitializedAnalytics, | ||
} from '../../types' | ||
import { createConsentStampingMiddleware } from '../consent-stamping' | ||
import { getPrunedCategories } from '../pruned-categories' | ||
import { validateAnalyticsInstance, validateCategories } from '../validation' | ||
|
||
/** | ||
* This class is a wrapper around the analytics.js library. | ||
*/ | ||
export class AnalyticsService { | ||
cdnSettings: Promise<CDNSettings> | ||
/** | ||
* The original analytics.load fn | ||
*/ | ||
loadNormally: AnyAnalytics['load'] | ||
|
||
private get analytics() { | ||
return getInitializedAnalytics(this._uninitializedAnalytics) | ||
} | ||
|
||
private _uninitializedAnalytics: AnyAnalytics | ||
|
||
constructor(analytics: AnyAnalytics) { | ||
validateAnalyticsInstance(analytics) | ||
this._uninitializedAnalytics = analytics | ||
this.loadNormally = analytics.load.bind(this._uninitializedAnalytics) | ||
this.cdnSettings = new Promise<CDNSettings>((resolve) => | ||
this.analytics.on('initialize', resolve) | ||
) | ||
} | ||
|
||
/** | ||
* Replace the load fn with a new one | ||
*/ | ||
replaceLoadMethod(loadFn: AnyAnalytics['load']) { | ||
this.analytics.load = loadFn | ||
} | ||
|
||
configureConsentStampingMiddleware({ | ||
getCategories, | ||
pruneUnmappedCategories, | ||
integrationCategoryMappings, | ||
}: Pick< | ||
CreateWrapperSettings, | ||
'getCategories' | 'pruneUnmappedCategories' | 'integrationCategoryMappings' | ||
>): void { | ||
// normalize getCategories pruning is turned on or off | ||
const getCategoriesForConsentStamping = async (): Promise<Categories> => { | ||
if (pruneUnmappedCategories) { | ||
return getPrunedCategories( | ||
getCategories, | ||
await this.cdnSettings, | ||
integrationCategoryMappings | ||
) | ||
} else { | ||
return getCategories() | ||
} | ||
} | ||
|
||
const MW = createConsentStampingMiddleware(getCategoriesForConsentStamping) | ||
return this.analytics.addSourceMiddleware(MW) | ||
} | ||
|
||
/** | ||
* Dispatch an event that looks like: | ||
* ```ts | ||
* { | ||
* "type": "track", | ||
* "event": "Segment Consent Preference", | ||
* "context": { | ||
* "consent": { | ||
* "categoryPreferences" : { | ||
* "C0001": true, | ||
* "C0002": false, | ||
* } | ||
* } | ||
* ... | ||
* ``` | ||
*/ | ||
consentChange(categories: Categories): void { | ||
try { | ||
validateCategories(categories) | ||
} catch (e: unknown) { | ||
// not sure if there's a better way to handle this | ||
return console.error(e) | ||
} | ||
const CONSENT_CHANGED_EVENT = 'Segment Consent Preference' | ||
this.analytics.track(CONSENT_CHANGED_EVENT, undefined, { | ||
consent: { categoryPreferences: categories }, | ||
}) | ||
} | ||
} | ||
|
||
/** | ||
* Get possibly-initialized analytics. | ||
* | ||
* Reason: | ||
* There is a known bug for people who attempt to to wrap the library: the analytics reference does not get updated when the analytics.js library loads. | ||
* Thus, we need to proxy events to the global reference instead. | ||
* | ||
* There is a universal fix here: however, many users may not have updated it: | ||
* https://github.com/segmentio/snippet/commit/081faba8abab0b2c3ec840b685c4ff6d6cccf79c | ||
*/ | ||
export const getInitializedAnalytics = ( | ||
analytics: AnyAnalytics | ||
): MaybeInitializedAnalytics => { | ||
const isSnippetUser = Array.isArray(analytics) | ||
if (isSnippetUser) { | ||
const opts = (analytics as any)._loadOptions ?? {} | ||
const globalAnalytics: MaybeInitializedAnalytics | undefined = ( | ||
window as any | ||
)[opts?.globalAnalyticsKey ?? 'analytics'] | ||
// we could probably skip this check and always return globalAnalytics, since they _should_ be set to the same thing at this point | ||
// however, it is safer to keep buffering. | ||
if ((globalAnalytics as any)?.initialized) { | ||
return globalAnalytics! | ||
} | ||
} | ||
|
||
return analytics | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { AnalyticsService } from './analytics-service' |
Oops, something went wrong.