Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consent consent changed event #936

Merged
merged 20 commits into from
Aug 22, 2023
6 changes: 6 additions & 0 deletions .changeset/mean-geese-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@segment/analytics-consent-tools': minor
'@segment/analytics-consent-wrapper-onetrust': minor
---

Add consent changed event
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@internal/consent-tools-integration-tests",
"private": true,
"scripts": {
".": "yarn -T turbo run --filter=@internal/consent-tools-integration-tests",
".": "yarn run -T turbo run --filter=@internal/consent-tools-integration-tests...",
"dev": "yarn concurrently 'yarn watch' 'yarn build serve --open'",
"build": "webpack",
"watch": "yarn build --watch",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { oneTrust } from '@segment/analytics-consent-wrapper-onetrust'
export const analytics = new AnalyticsBrowser()

oneTrust(analytics, {
disableConsentChangedEvent: false,
integrationCategoryMappings: {
Fullstory: ['C0001'],
'Actions Amplitude': ['C0004'],
Expand Down
31 changes: 19 additions & 12 deletions packages/consent/consent-tools/README.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,42 @@
# @segment/analytics-consent-tools



## Quick Start

```ts
// wrapper.js
import { createWrapper, resolveWhen } from '@segment/analytics-consent-tools'

export const withCMP = createWrapper({
shouldLoad: (ctx) => {
await resolveWhen(() =>
window.CMP !== undefined && !window.CMP.popUpVisible(), 500)
await resolveWhen(
() => window.CMP !== undefined && !window.CMP.popUpVisible(),
500
)

if (noConsentNeeded) {
ctx.abort({ loadSegmentNormally: true })
} else if (allTrackingDisabled) {
ctx.abort({ loadSegmentNormally: false })
}
},
getCategories: () => {
getCategories: () => {
// e.g. { Advertising: true, Functional: false }
return normalizeCategories(window.CMP.consentedCategories())
}
return normalizeCategories(window.CMP.consentedCategories())
},
})
```


## Wrapper Usage API

## `npm`

```js
import { withCMP } from './wrapper'
import { AnalyticsBrowser } from '@segment/analytics-next'

export const analytics = new AnalyticsBrowser()

withCmp(analytics)
withCMP(analytics)

analytics.load({
writeKey: '<MY_WRITE_KEY'>
Expand All @@ -43,36 +45,41 @@ analytics.load({
```

## Snippet users (window.analytics)

1. Delete the `analytics.load()` line from the snippet

```diff
- analytics.load("<MY_WRITE_KEY>");
```

2. Import Analytics

```js
import { withCMP } from './wrapper'

withCmp(window.analytics)
withCMP(window.analytics)

window.analytics.load('<MY_WRITE_KEY')
```

## Wrapper Examples

- [OneTrust](../consent-wrapper-onetrust) (beta)

## Settings / Options / Configuration

See the complete list of settings in the **[Settings interface](src/types/settings.ts)**

## Development

1. Build this package + all dependencies

```sh
yarn . build
```

2. Run tests

```
yarn test
```


Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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 {
CreateWrapperSettings,
AnyAnalytics,
CDNSettings,
AnalyticsBrowserSettings,
Categories,
} from '../../types'
import { CDNSettingsBuilder } from '@internal/test-helpers'
import { assertIntegrationsContainOnly } from './assertions/integrations-assertions'
Expand All @@ -29,6 +31,7 @@ const mockGetCategories: jest.MockedFn<CreateWrapperSettings['getCategories']> =
const analyticsLoadSpy: jest.MockedFn<AnyAnalytics['load']> = jest.fn()
const addSourceMiddlewareSpy = jest.fn()
let analyticsOnSpy: jest.MockedFn<AnyAnalytics['on']>
const analyticsTrackSpy: jest.MockedFn<AnyAnalytics['track']> = jest.fn()
let consoleErrorSpy: jest.SpiedFunction<typeof console['error']>

const getAnalyticsLoadLastCall = () => {
Expand Down Expand Up @@ -61,6 +64,7 @@ beforeEach(() => {
})

class MockAnalytics implements AnyAnalytics {
track = analyticsTrackSpy
on = analyticsOnSpy
load = analyticsLoadSpy
addSourceMiddleware = addSourceMiddlewareSpy
Expand Down Expand Up @@ -300,8 +304,16 @@ describe(createWrapper, () => {
)
})

describe('Validation', () => {
it('should throw an error if categories are in the wrong format', async () => {
describe('Settings Validation', () => {
/* NOTE: This test suite is meant to be minimal -- please see validation/__tests__ */

test('createWrapper should throw if user-defined settings/configuration/options are invalid', () => {
expect(() =>
wrapTestAnalytics({ getCategories: {} as any })
).toThrowError(/validation/i)
})

test('analytics.load should reject if categories are in the wrong format', async () => {
wrapTestAnalytics({
shouldLoad: () => Promise.resolve('sup' as any),
})
Expand All @@ -310,7 +322,7 @@ describe(createWrapper, () => {
)
})

it('should throw an error if categories are undefined', async () => {
test('analytics.load should reject if categories are undefined', async () => {
wrapTestAnalytics({
getCategories: () => undefined as any,
shouldLoad: () => undefined,
Expand Down Expand Up @@ -736,4 +748,60 @@ describe(createWrapper, () => {
})
})
})

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

let categoriesChangedCb: (categories: Categories) => void = () => {
throw new Error('Not implemented')
}

const registerOnConsentChanged = jest.fn(
(consentChangedCb: (c: Categories) => void) => {
// simulate a OneTrust.onConsentChanged event callback
categoriesChangedCb = jest.fn((categories: Categories) =>
consentChangedCb(categories)
)
}
)
it('should expect a callback', async () => {
wrapTestAnalytics({
registerOnConsentChanged: registerOnConsentChanged,
})
await analytics.load(DEFAULT_LOAD_SETTINGS)

expect(sendConsentChangedEventSpy).not.toBeCalled()
expect(registerOnConsentChanged).toBeCalledTimes(1)
categoriesChangedCb({ C0001: true, C0002: false })
expect(registerOnConsentChanged).toBeCalledTimes(1)
expect(sendConsentChangedEventSpy).toBeCalledTimes(1)

// if OnConsentChanged callback is called with categories, it should send event
expect(analyticsTrackSpy).toBeCalledWith(
'Segment Consent Preference',
undefined,
{ consent: { categoryPreferences: { C0001: true, C0002: false } } }
)
})
it('should throw an error if categories are invalid', async () => {
consoleErrorSpy.mockImplementationOnce(() => undefined)

wrapTestAnalytics({
registerOnConsentChanged: registerOnConsentChanged,
})

await analytics.load(DEFAULT_LOAD_SETTINGS)
expect(consoleErrorSpy).not.toBeCalled()
categoriesChangedCb(['OOPS'] as any)
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()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ import type {
AnalyticsSnippet,
AnalyticsBrowser,
} from '@segment/analytics-next'
import { createWrapper } from '../../index'
import { createWrapper, AnyAnalytics } from '../../index'

type Extends<T, U> = T extends U ? true : false

{
const wrap = createWrapper({ getCategories: () => ({ foo: true }) })
wrap({} as AnalyticsBrowser)
wrap({} as AnalyticsSnippet)

// see AnalyticsSnippet and AnalyticsBrowser extend AnyAnalytics
const f: Extends<AnalyticsSnippet, AnyAnalytics> = true
const g: Extends<AnalyticsBrowser, AnyAnalytics> = true
console.log(f, g)
}
36 changes: 36 additions & 0 deletions packages/consent/consent-tools/src/domain/consent-changed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { AnyAnalytics, Categories } from '../types'

/**
* Dispatch an event that looks like:
* ```ts
* {
* "type": "track",
* "event": "Segment Consent Preference",
* "context": {
* "consent": {
* "categoryPreferences" : {
* "C0001": true,
* "C0002": false,
* }
* }
* ...
* ```
*/
export const sendConsentChangedEvent = (
analytics: AnyAnalytics,
categories: Categories
): void => {
analytics.track(
CONSENT_CHANGED_EVENT,
undefined,
createConsentChangedCtxDto(categories)
)
}

const CONSENT_CHANGED_EVENT = 'Segment Consent Preference'

const createConsentChangedCtxDto = (categories: Categories) => ({
consent: {
categoryPreferences: categories,
},
})
22 changes: 20 additions & 2 deletions packages/consent/consent-tools/src/domain/create-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ import {
CreateWrapperSettings,
CDNSettings,
} from '../types'
import { validateCategories, validateOptions } from './validation'
import {
validateAnalyticsInstance,
validateCategories,
validateSettings,
} from './validation'
import { createConsentStampingMiddleware } from './consent-stamping'
import { pipe, pick, uniq } from '../utils'
import { AbortLoadError, LoadContext } from './load-cancellation'
import { ValidationError } from './validation/validation-error'
import { sendConsentChangedEvent } from './consent-changed'

export const createWrapper: CreateWrapper = (createWrapperOptions) => {
validateOptions(createWrapperOptions)
validateSettings(createWrapperOptions)

const {
shouldDisableSegment,
Expand All @@ -23,9 +28,11 @@ export const createWrapper: CreateWrapper = (createWrapperOptions) => {
integrationCategoryMappings,
shouldEnableIntegration,
pruneUnmappedCategories,
registerOnConsentChanged,
} = createWrapperOptions

return (analytics) => {
validateAnalyticsInstance(analytics)
const ogLoad = analytics.load

const loadWithConsent: AnyAnalytics['load'] = async (
Expand Down Expand Up @@ -118,6 +125,17 @@ export const createWrapper: CreateWrapper = (createWrapperOptions) => {
createConsentStampingMiddleware(getValidCategoriesForConsentStamping)
)

// whenever consent changes, dispatch a new event with the latest consent information
registerOnConsentChanged?.((categories) => {
try {
validateCategories(categories)
sendConsentChangedEvent(analytics, categories)
} catch (err) {
// Not sure if there's a better way to handle this, but this makes testing a bit easier.
console.error(err)
}
})

const updateCDNSettings: InitOptions['updateCDNSettings'] = (
cdnSettings
) => {
Expand Down
Loading