Skip to content

Commit dcf279c

Browse files
authored
Refactor consent wrapper to add analytics service (#997)
1 parent d9b47c4 commit dcf279c

File tree

18 files changed

+383
-134
lines changed

18 files changed

+383
-134
lines changed

.changeset/loud-rabbits-worry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@segment/analytics-consent-tools': patch
3+
---
4+
5+
Refactor internally to add AnalyticsService

examples/standalone-playground/pages/index-consent-no-banner.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
data-domain-script="09b87b90-1b30-4d68-9610-9d2fe11234f3-test"></script>
3434
<script type="text/javascript">
3535
const wk = document.querySelector("input").value
36-
wk && console.log('should be on the following url ->', `${window.location.origin + window.location.pathname}?writeKey=${wk}&otpreview=true&otgeo=za`)
36+
console.log('should be on the following url ->', `${window.location.origin + window.location.pathname}?writeKey=${wk || '<writekey>'}&otpreview=true&otgeo=za`)
3737
function OptanonWrapper() {
3838
// debugging.
3939
if (!window.OnetrustActiveGroups.replaceAll(',', '')) {
@@ -147,7 +147,7 @@ <h2>Logs</h2>
147147

148148
<script type="text/javascript">
149149
const displayConsentLogs = (str) => document.querySelector('#consent-changed').textContent = str
150-
analytics.on('track', (name, properties, options) => {
150+
window.analytics?.on('track', (name, properties, options) => {
151151
if (name.includes("Segment Consent")) {
152152
displayConsentLogs("Consent Changed Event Fired")
153153
setTimeout(() => displayConsentLogs(''), 3000)

packages/consent/consent-tools/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"dist/",
1010
"src/",
1111
"!**/__tests__/**",
12-
"!*.tsbuildinfo"
12+
"!*.tsbuildinfo",
13+
"!**/test-helpers/**"
1314
],
1415
"scripts": {
1516
".": "yarn run -T turbo run --filter=@segment/analytics-consent-tools",

packages/consent/consent-tools/src/domain/__tests__/consent-stamping.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ describe(createConsentStampingMiddleware, () => {
77
const getCategories = jest.fn()
88
const payload = {
99
obj: {
10-
type: 'track',
1110
context: new Context({ type: 'track' }),
1211
},
1312
}

packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import * as ConsentStamping from '../consent-stamping'
2-
import * as ConsentChanged from '../consent-changed'
32
import { createWrapper } from '../create-wrapper'
43
import { AbortLoadError, LoadContext } from '../load-cancellation'
54
import type {
@@ -11,6 +10,7 @@ import type {
1110
} from '../../types'
1211
import { CDNSettingsBuilder } from '@internal/test-helpers'
1312
import { assertIntegrationsContainOnly } from './assertions/integrations-assertions'
13+
import { AnalyticsService } from '../analytics'
1414

1515
const DEFAULT_LOAD_SETTINGS = {
1616
writeKey: 'foo',
@@ -661,8 +661,8 @@ describe(createWrapper, () => {
661661

662662
describe('registerOnConsentChanged', () => {
663663
const sendConsentChangedEventSpy = jest.spyOn(
664-
ConsentChanged,
665-
'sendConsentChangedEvent'
664+
AnalyticsService.prototype,
665+
'consentChange'
666666
)
667667

668668
let categoriesChangedCb: (categories: Categories) => void = () => {
@@ -709,8 +709,6 @@ describe(createWrapper, () => {
709709
expect(consoleErrorSpy).toBeCalledTimes(1)
710710
const err = consoleErrorSpy.mock.lastCall[0]
711711
expect(err.toString()).toMatch(/validation/i)
712-
// if OnConsentChanged callback is called with categories, it should send event
713-
expect(sendConsentChangedEventSpy).not.toBeCalled()
714712
expect(analyticsTrackSpy).not.toBeCalled()
715713
})
716714
})
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { AnalyticsService, getInitializedAnalytics } from '../analytics-service'
2+
import { analyticsMock } from '../../../test-helpers/mocks'
3+
import { ValidationError } from '../../validation/validation-error'
4+
5+
describe(AnalyticsService, () => {
6+
let analyticsService: AnalyticsService
7+
8+
beforeEach(() => {
9+
analyticsService = new AnalyticsService(analyticsMock)
10+
})
11+
12+
describe('constructor', () => {
13+
it('should throw an error if the analytics instance is not valid', () => {
14+
// @ts-ignore
15+
expect(() => new AnalyticsService(undefined)).toThrowError(
16+
ValidationError
17+
)
18+
})
19+
})
20+
21+
describe('cdnSettings', () => {
22+
it('should be a promise', async () => {
23+
expect(analyticsMock.on).toBeCalledTimes(1)
24+
expect(analyticsMock.on.mock.lastCall[0]).toBe('initialize')
25+
analyticsMock.on.mock.lastCall[1]({ integrations: {} })
26+
27+
await expect(analyticsService['cdnSettings']).resolves.toEqual({
28+
integrations: {},
29+
})
30+
})
31+
})
32+
33+
describe('loadNormally', () => {
34+
it('loads normally', () => {
35+
analyticsService = new AnalyticsService(analyticsMock)
36+
analyticsService.loadNormally('foo')
37+
expect(analyticsMock.load).toBeCalled()
38+
})
39+
40+
it('uses the correct value of *this*', () => {
41+
let that: any
42+
function fn(this: any) {
43+
// eslint-disable-next-line @typescript-eslint/no-this-alias
44+
that = this
45+
}
46+
const _analyticsMock = {
47+
...analyticsMock,
48+
load: fn,
49+
name: 'some instance',
50+
}
51+
analyticsService = new AnalyticsService(_analyticsMock)
52+
analyticsService.loadNormally('foo')
53+
expect(that.name).toEqual('some instance')
54+
})
55+
56+
it('will always call the original .load method', () => {
57+
const ogLoad = jest.fn()
58+
analyticsService = new AnalyticsService({
59+
...analyticsMock,
60+
load: ogLoad,
61+
})
62+
const replaceLoadMethod = jest.fn()
63+
analyticsService.replaceLoadMethod(replaceLoadMethod)
64+
analyticsService.loadNormally('foo')
65+
expect(ogLoad).toHaveBeenCalled()
66+
analyticsService.replaceLoadMethod(replaceLoadMethod)
67+
analyticsService.loadNormally('foo')
68+
expect(replaceLoadMethod).not.toBeCalled()
69+
})
70+
})
71+
72+
describe('replaceLoadMethod', () => {
73+
it('should replace the load method with the provided function', () => {
74+
const replaceLoadMethod = jest.fn()
75+
analyticsService.replaceLoadMethod(replaceLoadMethod)
76+
expect(analyticsService['analytics'].load).toBe(replaceLoadMethod)
77+
})
78+
})
79+
80+
describe('configureConsentStampingMiddleware', () => {
81+
// More tests are in create-wrapper.test.ts... should probably move the integration-y tests here
82+
it('should add the middleware to the analytics instance', () => {
83+
analyticsService.configureConsentStampingMiddleware({
84+
getCategories: () => ({
85+
C0001: true,
86+
}),
87+
})
88+
expect(analyticsMock.addSourceMiddleware).toBeCalledTimes(1)
89+
expect(analyticsMock.addSourceMiddleware).toBeCalledWith(
90+
expect.any(Function)
91+
)
92+
})
93+
94+
it('should stamp consent', async () => {
95+
const payload = {
96+
obj: {
97+
context: {},
98+
},
99+
}
100+
analyticsService.configureConsentStampingMiddleware({
101+
getCategories: () => ({
102+
C0001: true,
103+
C0002: false,
104+
}),
105+
})
106+
await analyticsMock.addSourceMiddleware.mock.lastCall[0]({
107+
payload,
108+
next: jest.fn(),
109+
})
110+
expect((payload.obj.context as any).consent).toEqual({
111+
categoryPreferences: {
112+
C0001: true,
113+
C0002: false,
114+
},
115+
})
116+
})
117+
})
118+
119+
describe('consentChange', () => {
120+
it('should call the track method with the expected arguments', () => {
121+
const mockCategories = { C0001: true, C0002: false }
122+
analyticsService.consentChange(mockCategories)
123+
expect(analyticsMock.track).toBeCalledWith(
124+
'Segment Consent Preference',
125+
undefined,
126+
{ consent: { categoryPreferences: mockCategories } }
127+
)
128+
})
129+
130+
it('should log an error if the categories are invalid', () => {
131+
const mockCategories = { invalid: 'nope' } as any
132+
console.error = jest.fn()
133+
analyticsService.consentChange(mockCategories)
134+
expect(console.error).toBeCalledTimes(1)
135+
expect(console.error).toBeCalledWith(expect.any(ValidationError))
136+
})
137+
})
138+
})
139+
140+
describe(getInitializedAnalytics, () => {
141+
beforeEach(() => {
142+
delete (window as any).analytics
143+
delete (window as any).foo
144+
})
145+
146+
it('should return the window.analytics object if the snippet user passes a stale reference', () => {
147+
;(window as any).analytics = { initialized: true }
148+
const analytics = [] as any
149+
expect(getInitializedAnalytics(analytics)).toEqual(
150+
(window as any).analytics
151+
)
152+
})
153+
154+
it('should return the correct global analytics instance if the user has set a globalAnalyticsKey', () => {
155+
;(window as any).foo = { initialized: true }
156+
const analytics = [] as any
157+
analytics._loadOptions = { globalAnalyticsKey: 'foo' }
158+
expect(getInitializedAnalytics(analytics)).toEqual((window as any).foo)
159+
})
160+
161+
it('should return the buffered instance if analytics is not initialized', () => {
162+
const analytics = [] as any
163+
const globalAnalytics = { initialized: false }
164+
// @ts-ignore
165+
window['analytics'] = globalAnalytics
166+
expect(getInitializedAnalytics(analytics)).toEqual(analytics)
167+
})
168+
it('invariant: should not throw if global analytics is undefined', () => {
169+
;(window as any).analytics = undefined
170+
const analytics = [] as any
171+
expect(getInitializedAnalytics(analytics)).toBe(analytics)
172+
})
173+
174+
it('should return the analytics object if it is not an array', () => {
175+
const analytics = { initialized: false } as any
176+
expect(getInitializedAnalytics(analytics)).toBe(analytics)
177+
})
178+
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {
2+
AnyAnalytics,
3+
Categories,
4+
CDNSettings,
5+
CreateWrapperSettings,
6+
MaybeInitializedAnalytics,
7+
} from '../../types'
8+
import { createConsentStampingMiddleware } from '../consent-stamping'
9+
import { getPrunedCategories } from '../pruned-categories'
10+
import { validateAnalyticsInstance, validateCategories } from '../validation'
11+
12+
/**
13+
* This class is a wrapper around the analytics.js library.
14+
*/
15+
export class AnalyticsService {
16+
cdnSettings: Promise<CDNSettings>
17+
/**
18+
* The original analytics.load fn
19+
*/
20+
loadNormally: AnyAnalytics['load']
21+
22+
private get analytics() {
23+
return getInitializedAnalytics(this._uninitializedAnalytics)
24+
}
25+
26+
private _uninitializedAnalytics: AnyAnalytics
27+
28+
constructor(analytics: AnyAnalytics) {
29+
validateAnalyticsInstance(analytics)
30+
this._uninitializedAnalytics = analytics
31+
this.loadNormally = analytics.load.bind(this._uninitializedAnalytics)
32+
this.cdnSettings = new Promise<CDNSettings>((resolve) =>
33+
this.analytics.on('initialize', resolve)
34+
)
35+
}
36+
37+
/**
38+
* Replace the load fn with a new one
39+
*/
40+
replaceLoadMethod(loadFn: AnyAnalytics['load']) {
41+
this.analytics.load = loadFn
42+
}
43+
44+
configureConsentStampingMiddleware({
45+
getCategories,
46+
pruneUnmappedCategories,
47+
integrationCategoryMappings,
48+
}: Pick<
49+
CreateWrapperSettings,
50+
'getCategories' | 'pruneUnmappedCategories' | 'integrationCategoryMappings'
51+
>): void {
52+
// normalize getCategories pruning is turned on or off
53+
const getCategoriesForConsentStamping = async (): Promise<Categories> => {
54+
if (pruneUnmappedCategories) {
55+
return getPrunedCategories(
56+
getCategories,
57+
await this.cdnSettings,
58+
integrationCategoryMappings
59+
)
60+
} else {
61+
return getCategories()
62+
}
63+
}
64+
65+
const MW = createConsentStampingMiddleware(getCategoriesForConsentStamping)
66+
return this.analytics.addSourceMiddleware(MW)
67+
}
68+
69+
/**
70+
* Dispatch an event that looks like:
71+
* ```ts
72+
* {
73+
* "type": "track",
74+
* "event": "Segment Consent Preference",
75+
* "context": {
76+
* "consent": {
77+
* "categoryPreferences" : {
78+
* "C0001": true,
79+
* "C0002": false,
80+
* }
81+
* }
82+
* ...
83+
* ```
84+
*/
85+
consentChange(categories: Categories): void {
86+
try {
87+
validateCategories(categories)
88+
} catch (e: unknown) {
89+
// not sure if there's a better way to handle this
90+
return console.error(e)
91+
}
92+
const CONSENT_CHANGED_EVENT = 'Segment Consent Preference'
93+
this.analytics.track(CONSENT_CHANGED_EVENT, undefined, {
94+
consent: { categoryPreferences: categories },
95+
})
96+
}
97+
}
98+
99+
/**
100+
* Get possibly-initialized analytics.
101+
*
102+
* Reason:
103+
* 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.
104+
* Thus, we need to proxy events to the global reference instead.
105+
*
106+
* There is a universal fix here: however, many users may not have updated it:
107+
* https://github.com/segmentio/snippet/commit/081faba8abab0b2c3ec840b685c4ff6d6cccf79c
108+
*/
109+
export const getInitializedAnalytics = (
110+
analytics: AnyAnalytics
111+
): MaybeInitializedAnalytics => {
112+
const isSnippetUser = Array.isArray(analytics)
113+
if (isSnippetUser) {
114+
const opts = (analytics as any)._loadOptions ?? {}
115+
const globalAnalytics: MaybeInitializedAnalytics | undefined = (
116+
window as any
117+
)[opts?.globalAnalyticsKey ?? 'analytics']
118+
// we could probably skip this check and always return globalAnalytics, since they _should_ be set to the same thing at this point
119+
// however, it is safer to keep buffering.
120+
if ((globalAnalytics as any)?.initialized) {
121+
return globalAnalytics!
122+
}
123+
}
124+
125+
return analytics
126+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { AnalyticsService } from './analytics-service'

0 commit comments

Comments
 (0)