Skip to content

Commit

Permalink
Refactor consent wrapper to add analytics service (#997)
Browse files Browse the repository at this point in the history
  • Loading branch information
silesky authored Nov 16, 2023
1 parent d9b47c4 commit dcf279c
Show file tree
Hide file tree
Showing 18 changed files with 383 additions and 134 deletions.
5 changes: 5 additions & 0 deletions .changeset/loud-rabbits-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@segment/analytics-consent-tools': patch
---

Refactor internally to add AnalyticsService
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
data-domain-script="09b87b90-1b30-4d68-9610-9d2fe11234f3-test"></script>
<script type="text/javascript">
const wk = document.querySelector("input").value
wk && console.log('should be on the following url ->', `${window.location.origin + window.location.pathname}?writeKey=${wk}&otpreview=true&otgeo=za`)
console.log('should be on the following url ->', `${window.location.origin + window.location.pathname}?writeKey=${wk || '<writekey>'}&otpreview=true&otgeo=za`)
function OptanonWrapper() {
// debugging.
if (!window.OnetrustActiveGroups.replaceAll(',', '')) {
Expand Down Expand Up @@ -147,7 +147,7 @@ <h2>Logs</h2>

<script type="text/javascript">
const displayConsentLogs = (str) => document.querySelector('#consent-changed').textContent = str
analytics.on('track', (name, properties, options) => {
window.analytics?.on('track', (name, properties, options) => {
if (name.includes("Segment Consent")) {
displayConsentLogs("Consent Changed Event Fired")
setTimeout(() => displayConsentLogs(''), 3000)
Expand Down
3 changes: 2 additions & 1 deletion packages/consent/consent-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"dist/",
"src/",
"!**/__tests__/**",
"!*.tsbuildinfo"
"!*.tsbuildinfo",
"!**/test-helpers/**"
],
"scripts": {
".": "yarn run -T turbo run --filter=@segment/analytics-consent-tools",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ describe(createConsentStampingMiddleware, () => {
const getCategories = jest.fn()
const payload = {
obj: {
type: 'track',
context: new Context({ type: 'track' }),
},
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as ConsentStamping from '../consent-stamping'
import * as ConsentChanged from '../consent-changed'
import { createWrapper } from '../create-wrapper'
import { AbortLoadError, LoadContext } from '../load-cancellation'
import type {
Expand All @@ -11,6 +10,7 @@ import type {
} from '../../types'
import { CDNSettingsBuilder } from '@internal/test-helpers'
import { assertIntegrationsContainOnly } from './assertions/integrations-assertions'
import { AnalyticsService } from '../analytics'

const DEFAULT_LOAD_SETTINGS = {
writeKey: 'foo',
Expand Down Expand Up @@ -661,8 +661,8 @@ describe(createWrapper, () => {

describe('registerOnConsentChanged', () => {
const sendConsentChangedEventSpy = jest.spyOn(
ConsentChanged,
'sendConsentChangedEvent'
AnalyticsService.prototype,
'consentChange'
)

let categoriesChangedCb: (categories: Categories) => void = () => {
Expand Down Expand Up @@ -709,8 +709,6 @@ describe(createWrapper, () => {
expect(consoleErrorSpy).toBeCalledTimes(1)
const err = consoleErrorSpy.mock.lastCall[0]
expect(err.toString()).toMatch(/validation/i)
// if OnConsentChanged callback is called with categories, it should send event
expect(sendConsentChangedEventSpy).not.toBeCalled()
expect(analyticsTrackSpy).not.toBeCalled()
})
})
Expand Down
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)
})
})
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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AnalyticsService } from './analytics-service'
Loading

0 comments on commit dcf279c

Please sign in to comment.