diff --git a/.changeset/unlucky-kids-invite.md b/.changeset/unlucky-kids-invite.md new file mode 100644 index 000000000..7af3c7a08 --- /dev/null +++ b/.changeset/unlucky-kids-invite.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-next': patch +--- + +Add 'disable' boolean option to allow for disabling Segment in a testing environment. diff --git a/packages/browser/package.json b/packages/browser/package.json index ad673ca04..118f142c7 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -44,7 +44,7 @@ "size-limit": [ { "path": "dist/umd/index.js", - "limit": "29.2 KB" + "limit": "29.5 KB" } ], "dependencies": { diff --git a/packages/browser/src/browser/__tests__/integration.test.ts b/packages/browser/src/browser/__tests__/integration.test.ts index f66471157..4a44cb2bc 100644 --- a/packages/browser/src/browser/__tests__/integration.test.ts +++ b/packages/browser/src/browser/__tests__/integration.test.ts @@ -23,7 +23,7 @@ import { highEntropyTestData, lowEntropyTestData, } from '../../test-helpers/fixtures/client-hints' -import { getGlobalAnalytics } from '../..' +import { getGlobalAnalytics, NullAnalytics } from '../..' let fetchCalls: ReturnType[] = [] @@ -94,11 +94,11 @@ const amplitudeWriteKey = 'bar' beforeEach(() => { setGlobalCDNUrl(undefined as any) + fetchCalls = [] }) describe('Initialization', () => { beforeEach(async () => { - fetchCalls = [] jest.resetAllMocks() jest.resetModules() }) @@ -1209,4 +1209,56 @@ describe('Options', () => { expect(integrationEvent.timestamp()).toBeInstanceOf(Date) }) }) + + describe('disable', () => { + /** + * Note: other tests in null-analytics.test.ts cover the NullAnalytics class (including persistence) + */ + it('should return a null version of analytics / context', async () => { + const [analytics, context] = await AnalyticsBrowser.load( + { + writeKey, + }, + { disable: true } + ) + expect(context).toBeInstanceOf(Context) + expect(analytics).toBeInstanceOf(NullAnalytics) + expect(analytics.initialized).toBe(true) + }) + + it('should not fetch cdn settings or dispatch events', async () => { + const [analytics] = await AnalyticsBrowser.load( + { + writeKey, + }, + { disable: true } + ) + await analytics.track('foo') + expect(fetchCalls.length).toBe(0) + }) + + it('should only accept a boolean value', async () => { + const [analytics] = await AnalyticsBrowser.load( + { + writeKey, + }, + // @ts-ignore + { disable: 'true' } + ) + expect(analytics).not.toBeInstanceOf(NullAnalytics) + }) + + it('should allow access to cdnSettings', async () => { + const disableSpy = jest.fn().mockReturnValue(true) + const [analytics] = await AnalyticsBrowser.load( + { + cdnSettings: { integrations: {}, foo: 123 }, + writeKey, + }, + { disable: disableSpy } + ) + expect(analytics).toBeInstanceOf(NullAnalytics) + expect(disableSpy).toBeCalledWith({ integrations: {}, foo: 123 }) + }) + }) }) diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts index 2c9da9128..9629478c7 100644 --- a/packages/browser/src/browser/index.ts +++ b/packages/browser/src/browser/index.ts @@ -2,7 +2,12 @@ import { getProcessEnv } from '../lib/get-process-env' import { getCDN, setGlobalCDNUrl } from '../lib/parse-cdn' import { fetch } from '../lib/fetch' -import { Analytics, AnalyticsSettings, InitOptions } from '../core/analytics' +import { + Analytics, + AnalyticsSettings, + NullAnalytics, + InitOptions, +} from '../core/analytics' import { Context } from '../core/context' import { Plan } from '../core/events' import { Plugin } from '../core/plugin' @@ -300,6 +305,11 @@ async function loadAnalytics( options: InitOptions = {}, preInitBuffer: PreInitMethodCallBuffer ): Promise<[Analytics, Context]> { + // return no-op analytics instance if disabled + if (options.disable === true) { + return [new NullAnalytics(), Context.system()] + } + if (options.globalAnalyticsKey) setGlobalAnalyticsKey(options.globalAnalyticsKey) // this is an ugly side-effect, but it's for the benefits of the plugins that get their cdn via getCDN() @@ -313,6 +323,14 @@ async function loadAnalytics( legacySettings = options.updateCDNSettings(legacySettings) } + // if options.disable is a function, we allow user to disable analytics based on CDN Settings + if (typeof options.disable === 'function') { + const disabled = await options.disable(legacySettings) + if (disabled) { + return [new NullAnalytics(), Context.system()] + } + } + const retryQueue: boolean = legacySettings.integrations['Segment.io']?.retryQueue ?? true diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/analytics.test.ts similarity index 100% rename from packages/browser/src/core/analytics/__tests__/integration.test.ts rename to packages/browser/src/core/analytics/__tests__/analytics.test.ts diff --git a/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts b/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts new file mode 100644 index 000000000..b2af82578 --- /dev/null +++ b/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts @@ -0,0 +1,37 @@ +import { getAjsBrowserStorage } from '../../../test-helpers/browser-storage' +import { Analytics, NullAnalytics } from '..' + +describe(NullAnalytics, () => { + it('should return an instance of Analytics / NullAnalytics', () => { + const analytics = new NullAnalytics() + expect(analytics).toBeInstanceOf(Analytics) + expect(analytics).toBeInstanceOf(NullAnalytics) + }) + + it('should have initialized set to true', () => { + const analytics = new NullAnalytics() + expect(analytics.initialized).toBe(true) + }) + + it('should have no plugins', async () => { + const analytics = new NullAnalytics() + expect(analytics.queue.plugins).toHaveLength(0) + }) + it('should dispatch events', async () => { + const analytics = new NullAnalytics() + const ctx = await analytics.track('foo') + expect(ctx.event.event).toBe('foo') + }) + + it('should have disableClientPersistence set to true', () => { + const analytics = new NullAnalytics() + expect(analytics.options.disableClientPersistence).toBe(true) + }) + + it('integration: should not touch cookies or localStorage', async () => { + const analytics = new NullAnalytics() + await analytics.track('foo') + const storage = getAjsBrowserStorage() + expect(Object.values(storage).every((v) => !v)).toBe(true) + }) +}) diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index db2f13d18..b396a8423 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -129,6 +129,24 @@ export interface InitOptions { * default: analytics */ globalAnalyticsKey?: string + + /** + * Disable sending any data to Segment's servers. All emitted events and API calls (including .ready()), will be no-ops, and no cookies or localstorage will be used. + * + * @example + * ### Basic (Will not not fetch any CDN settings) + * ```ts + * disable: process.env.NODE_ENV === 'test' + * ``` + * + * ### Advanced (Fetches CDN Settings. Do not use this unless you require CDN settings for some reason) + * ```ts + * disable: (cdnSettings) => cdnSettings.foo === 'bar' + * ``` + */ + disable?: + | boolean + | ((cdnSettings: LegacySettings) => boolean | Promise) } /* analytics-classic stubs */ @@ -652,3 +670,13 @@ export class Analytics an[method].apply(this, args) } } + +/** + * @returns a no-op analytics instance that does not create cookies or localstorage, or send any events to segment. + */ +export class NullAnalytics extends Analytics { + constructor() { + super({ writeKey: '' }, { disableClientPersistence: true }) + this.initialized = true + } +}