diff --git a/.changeset/calm-dancers-happen.md b/.changeset/calm-dancers-happen.md new file mode 100644 index 000000000..a744a17fe --- /dev/null +++ b/.changeset/calm-dancers-happen.md @@ -0,0 +1,9 @@ +--- +'@segment/analytics-consent-tools': minor +--- +Segment will not load, or, if already loaded, will not send events to segment, if all of the following conditions are met: +1. No destinations without a consent mapping (consentSettings.hasUnmappedDestinations == false) + + AND + +2. User has not consented to any category present in the consentSettings.allCategories array. diff --git a/.changeset/tame-cooks-invite.md b/.changeset/tame-cooks-invite.md new file mode 100644 index 000000000..139f7673b --- /dev/null +++ b/.changeset/tame-cooks-invite.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-next': patch +--- + +add hasUnmappedDestinations property to types diff --git a/examples/standalone-playground/pages/index-consent-no-banner.html b/examples/standalone-playground/pages/index-consent-no-banner.html index 7f5bba799..245b493ec 100644 --- a/examples/standalone-playground/pages/index-consent-no-banner.html +++ b/examples/standalone-playground/pages/index-consent-no-banner.html @@ -106,10 +106,7 @@ var t = document.createElement('script') t.type = 'text/javascript' t.async = !0 - t.src = - 'https://cdn.segment.com/analytics.js/v1/' + - writeKey + - '/analytics.min.js' + t.src = '/node_modules/@segment/analytics-next/dist/umd/standalone.js' var n = document.getElementsByTagName('script')[0] n.parentNode.insertBefore(t, n) analytics._loadOptions = e diff --git a/examples/standalone-playground/pages/index-consent.html b/examples/standalone-playground/pages/index-consent.html index c6a998fb6..64afa1da5 100644 --- a/examples/standalone-playground/pages/index-consent.html +++ b/examples/standalone-playground/pages/index-consent.html @@ -94,10 +94,7 @@ var t = document.createElement('script') t.type = 'text/javascript' t.async = !0 - t.src = - 'https://cdn.segment.com/analytics.js/v1/' + - writeKey + - '/analytics.min.js' + t.src = '/node_modules/@segment/analytics-next/dist/umd/standalone.js' var n = document.getElementsByTagName('script')[0] n.parentNode.insertBefore(t, n) analytics._loadOptions = e diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts index 9629478c7..98d592468 100644 --- a/packages/browser/src/browser/index.ts +++ b/packages/browser/src/browser/index.ts @@ -90,11 +90,16 @@ export interface LegacySettings { */ consentSettings?: { /** - * All unique consent categories. + * All unique consent categories for enabled destinations. * There can be categories in this array that are important for consent that are not included in any integration (e.g. 2 cloud mode categories). * @example ["Analytics", "Advertising", "CAT001"] */ allCategories: string[] + + /** + * Whether or not there are any unmapped destinations for enabled destinations. + */ + hasUnmappedDestinations: boolean } } diff --git a/packages/consent/consent-tools/package.json b/packages/consent/consent-tools/package.json index 491fe49eb..f03527b85 100644 --- a/packages/consent/consent-tools/package.json +++ b/packages/consent/consent-tools/package.json @@ -13,7 +13,7 @@ "!**/test-helpers/**" ], "scripts": { - ".": "yarn run -T turbo run --filter=@segment/analytics-consent-tools", + ".": "yarn run -T turbo run --filter=@segment/analytics-consent-tools...", "test": "yarn jest", "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'", "build": "rm -rf dist && yarn concurrently 'yarn:build:*'", diff --git a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts index 16de23e87..db7b8b120 100644 --- a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts +++ b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts @@ -1,4 +1,5 @@ import * as ConsentStamping from '../consent-stamping' +import * as DisableSegment from '../disable-segment' import { createWrapper } from '../create-wrapper' import { AbortLoadError, LoadContext } from '../load-cancellation' import type { @@ -12,18 +13,13 @@ import { CDNSettingsBuilder } from '@internal/test-helpers' import { assertIntegrationsContainOnly } from './assertions/integrations-assertions' import { AnalyticsService } from '../analytics' +jest.mock('../disable-segment') +const disableSegmentMock = jest.mocked(DisableSegment) + const DEFAULT_LOAD_SETTINGS = { writeKey: 'foo', cdnSettings: { integrations: {} }, } -/** - * Create consent settings for integrations - */ -const createConsentSettings = (categories: string[] = []) => ({ - consentSettings: { - categories, - }, -}) const mockGetCategories: jest.MockedFn = jest.fn().mockImplementation(() => ({ Advertising: true })) @@ -46,18 +42,13 @@ const getAnalyticsLoadLastCall = () => { } } -let analytics: AnyAnalytics, settingsBuilder: CDNSettingsBuilder +let analytics: AnyAnalytics, cdnSettingsBuilder: CDNSettingsBuilder beforeEach(() => { consoleErrorSpy = jest.spyOn(console, 'error') - - settingsBuilder = new CDNSettingsBuilder().addActionDestinationSettings({ - // add a default plugin just for safety - creationName: 'nope', - ...createConsentSettings(['Nope', 'Never']), - }) + cdnSettingsBuilder = new CDNSettingsBuilder() analyticsOnSpy = jest.fn().mockImplementation((event, fn) => { if (event === 'initialize') { - fn(settingsBuilder.build()) + fn(cdnSettingsBuilder.build()) } else { console.error('event not recognized') } @@ -80,7 +71,7 @@ const wrapTestAnalytics = (overrides: Partial = {}) => describe(createWrapper, () => { it('should allow load arguments to be forwarded correctly from the patched analytics.load to the underlying load method', async () => { - const mockCdnSettings = settingsBuilder.build() + const mockCdnSettings = cdnSettingsBuilder.build() wrapTestAnalytics() @@ -98,13 +89,15 @@ describe(createWrapper, () => { await analytics.load(loadSettings1, loadSettings2) const { args: loadCallArgs, updatedCDNSettings } = getAnalyticsLoadLastCall() - const [loadedSettings1, loadedSettings2] = loadCallArgs expect(loadCallArgs.length).toBe(2) - expect(loadedSettings1).toEqual(loadSettings1) - expect(Object.keys(loadedSettings1)).toEqual(Object.keys(loadSettings1)) + expect(Object.keys(loadCallArgs[0])).toEqual( + expect.arrayContaining(['cdnSettings', 'writeKey']) + ) - expect(Object.keys(loadedSettings2)).toEqual(Object.keys(loadSettings2)) + expect(Object.keys(loadCallArgs[1])).toEqual( + expect.arrayContaining(['anyOption', 'updateCDNSettings']) + ) expect(loadSettings2).toEqual(expect.objectContaining({ anyOption: 'foo' })) expect(updatedCDNSettings).toEqual( expect.objectContaining({ some_new_key: 123 }) @@ -166,7 +159,7 @@ describe(createWrapper, () => { ) it('should allow segment to be loaded normally (with all consent wrapper behavior disabled) via ctx.abort', async () => { - const mockCdnSettings = settingsBuilder.build() + const mockCdnSettings = cdnSettingsBuilder.build() wrapTestAnalytics({ shouldLoadSegment: (ctx) => { @@ -261,13 +254,12 @@ describe(createWrapper, () => { ])( 'if shouldLoadSegment() returns nil ($returnVal), intial categories will come from getCategories()', async ({ shouldLoadSegment }) => { - const mockCdnSettings = { - integrations: { - mockIntegration: { - ...createConsentSettings(['Advertising']), - }, - }, - } + const mockCdnSettings = cdnSettingsBuilder + .addActionDestinationSettings({ + creationName: 'mockIntegration', + consentSettings: { categories: ['Advertising'] }, + }) + .build() wrapTestAnalytics({ shouldLoadSegment: shouldLoadSegment, @@ -296,13 +288,12 @@ describe(createWrapper, () => { ])( 'if shouldLoadSegment() returns categories ($returnVal), those will be the initial categories', async ({ getCategories }) => { - const mockCdnSettings = { - integrations: { - mockIntegration: { - ...createConsentSettings(['Advertising']), - }, - }, - } + const mockCdnSettings = cdnSettingsBuilder + .addActionDestinationSettings({ + creationName: 'mockIntegration', + consentSettings: { categories: ['Advertising'] }, + }) + .build() mockGetCategories.mockImplementationOnce(getCategories) @@ -359,18 +350,22 @@ describe(createWrapper, () => { const creationNameWithConsentMatch = 'should.be.enabled.bc.consent.match' const creationNameWithConsentMismatch = 'should.be.disabled' - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings( { creationName: creationNameWithConsentMismatch, - ...createConsentSettings(['Foo']), + consentSettings: { + categories: ['Foo'], + }, }, { creationName: creationNameNoConsentData, }, { creationName: creationNameWithConsentMatch, - ...createConsentSettings(['Advertising']), + consentSettings: { + categories: ['Advertising'], + }, } ) .build() @@ -394,10 +389,12 @@ describe(createWrapper, () => { }) it('should allow integration if it has one category and user has consented to that category', async () => { - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'mockIntegration', - ...createConsentSettings(['Foo']), + consentSettings: { + categories: ['Foo'], + }, }) .build() @@ -419,10 +416,12 @@ describe(createWrapper, () => { }) it('should allow integration if it has multiple categories and user consents to all of them.', async () => { - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'mockIntegration', - ...createConsentSettings(['Foo', 'Bar']), + consentSettings: { + categories: ['Foo', 'Bar'], + }, }) .build() @@ -444,10 +443,12 @@ describe(createWrapper, () => { }) it('should disable integration if it has multiple categories but user has only consented to one', async () => { - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'mockIntegration', - ...createConsentSettings(['Foo', 'Bar']), + consentSettings: { + categories: ['Foo', 'Bar'], + }, }) .build() @@ -491,7 +492,7 @@ describe(createWrapper, () => { describe('shouldEnableIntegration', () => { it('should let user customize the logic that determines whether or not a destination is enabled', async () => { const disabledDestinationCreationName = 'DISABLED' - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings( { creationName: disabledDestinationCreationName, @@ -545,7 +546,7 @@ describe(createWrapper, () => { ConsentStamping, 'createConsentStampingMiddleware' ) - const mockCdnSettings = settingsBuilder.build() + const mockCdnSettings = cdnSettingsBuilder.build() wrapTestAnalytics({ getCategories, @@ -569,7 +570,7 @@ describe(createWrapper, () => { ConsentStamping, 'createConsentStampingMiddleware' ) - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'Some Other Plugin', }) @@ -594,10 +595,12 @@ describe(createWrapper, () => { ConsentStamping, 'createConsentStampingMiddleware' ) - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'Some Other Plugin', - ...createConsentSettings(['Foo']), + consentSettings: { + categories: ['Foo'], + }, }) .build() @@ -631,7 +634,7 @@ describe(createWrapper, () => { ConsentStamping, 'createConsentStampingMiddleware' ) - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'Some Other Plugin', }) @@ -712,4 +715,102 @@ describe(createWrapper, () => { expect(analyticsTrackSpy).not.toBeCalled() }) }) + + describe('Disabling Segment Automatically', () => { + // if user has no unmapped destinations and only irrelevant categories, we disable segment. + // for more tests, see disable-segment.test.ts + it('should always disable if segmentShouldBeDisabled returns true', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(true) + wrapTestAnalytics() + await analytics.load({ + ...DEFAULT_LOAD_SETTINGS, + }) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(true) + }) + it('should disable if segmentShouldBeDisabled returns false and disable is not overridden', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(false) + wrapTestAnalytics() + await analytics.load({ + ...DEFAULT_LOAD_SETTINGS, + }) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(false) + }) + + it('should be disabled if if a user overrides disabled with boolean: true, and pass through a boolean', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(false) + wrapTestAnalytics() + await analytics.load( + { + ...DEFAULT_LOAD_SETTINGS, + }, + { disable: true } + ) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable + ).toBe(true) + }) + + it('should return true if segment should be disabled, but a user loads a false value', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(true) + wrapTestAnalytics() + await analytics.load( + { + ...DEFAULT_LOAD_SETTINGS, + }, + { disable: false } + ) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(true) + }) + + it('should handle if a user overrides the value with a function', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(false) + wrapTestAnalytics() + await analytics.load( + { + ...DEFAULT_LOAD_SETTINGS, + }, + { disable: () => true } + ) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(true) + }) + + it('should enable if user passes the wrong option to "load"', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(false) + wrapTestAnalytics() + await analytics.load( + { + ...DEFAULT_LOAD_SETTINGS, + }, + // @ts-ignore + { disable: 'foo' } + ) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(false) + }) + }) }) diff --git a/packages/consent/consent-tools/src/domain/__tests__/disable-segment.test.ts b/packages/consent/consent-tools/src/domain/__tests__/disable-segment.test.ts new file mode 100644 index 000000000..33f3226f4 --- /dev/null +++ b/packages/consent/consent-tools/src/domain/__tests__/disable-segment.test.ts @@ -0,0 +1,67 @@ +import { CDNSettingsConsent } from '../../types' +import { segmentShouldBeDisabled } from '../disable-segment' + +describe('segmentShouldBeDisabled', () => { + it('should be disabled if user has only consented to irrelevant categories: multiple', () => { + const consentCategories = { foo: true, bar: true, baz: false } + const consentSettings: CDNSettingsConsent = { + allCategories: ['baz', 'qux'], + hasUnmappedDestinations: false, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + true + ) + }) + + it('should be disabled if user has only consented to irrelevant categories: single', () => { + const consentCategories = { foo: true } + const consentSettings = { + allCategories: ['bar'], + hasUnmappedDestinations: false, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + true + ) + }) + + it('should be enabled if there are any relevant categories consented to', () => { + const consentCategories = { foo: true, bar: true, baz: true } + const consentSettings: CDNSettingsConsent = { + allCategories: ['baz'], + hasUnmappedDestinations: false, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + false + ) + }) + + it('should be enabled if consentSettings is undefined', () => { + const consentCategories = { foo: true } + const consentSettings = undefined + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + false + ) + }) + + it('should be enabled if consentSettings has unmapped destinations', () => { + const consentCategories = { foo: true } + const consentSettings = { + allCategories: ['foo'], + hasUnmappedDestinations: true, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + false + ) + }) + + it('should be enabled if user has consented to all relevant categories', () => { + const consentCategories = { foo: true } + const consentSettings = { + allCategories: ['foo'], + hasUnmappedDestinations: false, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + false + ) + }) +}) diff --git a/packages/consent/consent-tools/src/domain/create-wrapper.ts b/packages/consent/consent-tools/src/domain/create-wrapper.ts index f4645d4c4..30a3dcd75 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -10,6 +10,7 @@ import { validateCategories, validateSettings } from './validation' import { pipe } from '../utils' import { AbortLoadError, LoadContext } from './load-cancellation' import { AnalyticsService } from './analytics' +import { segmentShouldBeDisabled } from './disable-segment' export const createWrapper = ( ...[createWrapperSettings]: Parameters> @@ -99,6 +100,7 @@ export const createWrapper = ( updateCDNSettings, options?.updateCDNSettings || ((id) => id) ), + disable: createDisableOption(initialCategories, options?.disable), }) } analyticsService.replaceLoadMethod(loadWithConsent) @@ -180,3 +182,18 @@ const disableIntegrations = ( ) return results } + +const createDisableOption = ( + initialCategories: Categories, + disable: InitOptions['disable'] +): NonNullable => { + if (disable === true) { + return true + } + return (cdnSettings: CDNSettings) => { + return ( + segmentShouldBeDisabled(initialCategories, cdnSettings.consentSettings) || + (typeof disable === 'function' ? disable(cdnSettings) : false) + ) + } +} diff --git a/packages/consent/consent-tools/src/domain/disable-segment.ts b/packages/consent/consent-tools/src/domain/disable-segment.ts new file mode 100644 index 000000000..7a06751ab --- /dev/null +++ b/packages/consent/consent-tools/src/domain/disable-segment.ts @@ -0,0 +1,18 @@ +import { Categories, CDNSettingsConsent } from '../types' + +/** + * @returns whether or not analytics.js should be completely disabled (never load, or drop cookies) + */ +export const segmentShouldBeDisabled = ( + consentCategories: Categories, + consentSettings: CDNSettingsConsent | undefined +): boolean => { + if (!consentSettings || consentSettings.hasUnmappedDestinations) { + return false + } + + // disable if _all_ of the the consented categories are irrelevant to segment + return Object.keys(consentCategories) + .filter((c) => consentCategories[c]) + .every((c) => !consentSettings.allCategories.includes(c)) +} diff --git a/packages/consent/consent-tools/src/types/wrapper.ts b/packages/consent/consent-tools/src/types/wrapper.ts index 3f4f75321..c270b3adf 100644 --- a/packages/consent/consent-tools/src/types/wrapper.ts +++ b/packages/consent/consent-tools/src/types/wrapper.ts @@ -12,6 +12,7 @@ export interface AnalyticsBrowserSettings { */ export interface InitOptions { updateCDNSettings?(cdnSettings: CDNSettings): CDNSettings + disable?: boolean | ((cdnSettings: CDNSettings) => boolean) } /** @@ -67,13 +68,17 @@ export interface IntegrationCategoryMappings { [integrationName: string]: string[] } +export interface CDNSettingsConsent { + // all unique categories keys + allCategories: string[] + // where user has unmapped enabled destinations + hasUnmappedDestinations: boolean +} + export interface CDNSettings { integrations: CDNSettingsIntegrations remotePlugins?: CDNSettingsRemotePlugin[] - consentSettings?: { - // all unique categories keys - allCategories: string[] - } + consentSettings?: CDNSettingsConsent } /**