From 6a23d0bc5d116325fee2868bc49eed91e37b0467 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Tue, 7 Nov 2023 18:09:00 -0600 Subject: [PATCH] Add disabled option --- .changeset/unlucky-kids-invite.md | 5 + packages/browser/package.json | 2 +- .../src/browser/__tests__/integration.test.ts | 43 +- packages/browser/src/browser/index.ts | 20 +- ...{integration.test.ts => analytics.test.ts} | 0 .../__tests__/null-analytics.test.ts | 38 + .../browser/src/core/analytics/analytics.ts | 671 ++++++++++++++++++ packages/browser/src/core/analytics/index.ts | 656 +---------------- .../src/core/analytics/null-analytics.ts | 11 + 9 files changed, 789 insertions(+), 657 deletions(-) create mode 100644 .changeset/unlucky-kids-invite.md rename packages/browser/src/core/analytics/__tests__/{integration.test.ts => analytics.test.ts} (100%) create mode 100644 packages/browser/src/core/analytics/__tests__/null-analytics.test.ts create mode 100644 packages/browser/src/core/analytics/analytics.ts create mode 100644 packages/browser/src/core/analytics/null-analytics.ts diff --git a/.changeset/unlucky-kids-invite.md b/.changeset/unlucky-kids-invite.md new file mode 100644 index 0000000000..7af3c7a08d --- /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 a9fff627e0..164ca75c3c 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -44,7 +44,7 @@ "size-limit": [ { "path": "dist/umd/index.js", - "limit": "29 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 f66471157b..2870c1b7d6 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,43 @@ 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) + }) + }) }) diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts index 39ac2ee83e..d134de86df 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 0000000000..7a02ad8ff5 --- /dev/null +++ b/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts @@ -0,0 +1,38 @@ +import { getAjsBrowserStorage } from '../../../test-helpers/browser-storage' +import { Analytics } from '../analytics' +import { NullAnalytics } from '../null-analytics' + +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/analytics.ts b/packages/browser/src/core/analytics/analytics.ts new file mode 100644 index 0000000000..5f02a72636 --- /dev/null +++ b/packages/browser/src/core/analytics/analytics.ts @@ -0,0 +1,671 @@ +import { + AliasParams, + DispatchedEvent, + EventParams, + GroupParams, + PageParams, + resolveAliasArguments, + resolveArguments, + resolvePageArguments, + resolveUserArguments, + IdentifyParams, +} from '../arguments-resolver' +import type { FormArgs, LinkArgs } from '../auto-track' +import { isOffline } from '../connection' +import { Context } from '../context' +import { dispatch, Emitter } from '@segment/analytics-core' +import { + Callback, + EventFactory, + Integrations, + Plan, + EventProperties, + SegmentEvent, +} from '../events' +import type { Plugin } from '../plugin' +import { EventQueue } from '../queue/event-queue' +import { Group, ID, User, UserOptions } from '../user' +import autoBind from '../../lib/bind-all' +import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' +import type { LegacyDestination } from '../../plugins/ajs-destination' +import type { + LegacyIntegration, + ClassicIntegrationSource, +} from '../../plugins/ajs-destination/types' +import type { + DestinationMiddlewareFunction, + MiddlewareFunction, +} from '../../plugins/middleware' +import { version } from '../../generated/version' +import { PriorityQueue } from '../../lib/priority-queue' +import { getGlobal } from '../../lib/get-global' +import { AnalyticsClassic, AnalyticsCore } from './interfaces' +import { HighEntropyHint } from '../../lib/client-hints/interfaces' +import type { LegacySettings } from '../../browser' +import { + CookieOptions, + MemoryStorage, + UniversalStorage, + StorageSettings, + StoreType, + applyCookieOptions, + initializeStorages, + isArrayOfStoreType, +} from '../storage' +import { PluginFactory } from '../../plugins/remote-loader' +import { setGlobalAnalytics } from '../../lib/global-analytics-helper' +import { popPageContext } from '../buffer' + +const deprecationWarning = + 'This is being deprecated and will be not be available in future releases of Analytics JS' + +// reference any pre-existing "analytics" object so a user can restore the reference +const global: any = getGlobal() +const _analytics = global?.analytics + +function createDefaultQueue( + name: string, + retryQueue = false, + disablePersistance = false +) { + const maxAttempts = retryQueue ? 10 : 1 + const priorityQueue = disablePersistance + ? new PriorityQueue(maxAttempts, []) + : new PersistedPriorityQueue(maxAttempts, name) + return new EventQueue(priorityQueue) +} + +export interface AnalyticsSettings { + writeKey: string + timeout?: number + plugins?: (Plugin | PluginFactory)[] + classicIntegrations?: ClassicIntegrationSource[] +} + +export interface InitOptions { + /** + * Disables storing any data on the client-side via cookies or localstorage. + * Defaults to `false`. + * + */ + disableClientPersistence?: boolean + /** + * Disables automatically converting ISO string event properties into Dates. + * ISO string to Date conversions occur right before sending events to a classic device mode integration, + * after any destination middleware have been ran. + * Defaults to `false`. + */ + disableAutoISOConversion?: boolean + initialPageview?: boolean + cookie?: CookieOptions + storage?: StorageSettings + user?: UserOptions + group?: UserOptions + integrations?: Integrations + plan?: Plan + retryQueue?: boolean + obfuscate?: boolean + /** + * This callback allows you to update/mutate CDN Settings. + * This is called directly after settings are fetched from the CDN. + */ + updateCDNSettings?: (settings: LegacySettings) => LegacySettings + /** + * Disables or sets constraints on processing of query string parameters + */ + useQueryString?: + | boolean + | { + aid?: RegExp + uid?: RegExp + } + /** + * Array of high entropy Client Hints to request. These may be rejected by the user agent - only required hints should be requested. + */ + highEntropyValuesClientHints?: HighEntropyHint[] + /** + * When using the snippet, this is the key that points to the global analytics instance (e.g. window.analytics). + * 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 + | ((LegacySettings: LegacySettings) => boolean | Promise) +} + +/* analytics-classic stubs */ +function _stub(this: never) { + console.warn(deprecationWarning) +} + +export class Analytics + extends Emitter + implements AnalyticsCore, AnalyticsClassic +{ + protected settings: AnalyticsSettings + private _user: User + private _group: Group + private eventFactory: EventFactory + private _debug = false + private _universalStorage: UniversalStorage + + initialized = false + integrations: Integrations + options: InitOptions + queue: EventQueue + + constructor( + settings: AnalyticsSettings, + options?: InitOptions, + queue?: EventQueue, + user?: User, + group?: Group + ) { + super() + const cookieOptions = options?.cookie + const disablePersistance = options?.disableClientPersistence ?? false + this.settings = settings + this.settings.timeout = this.settings.timeout ?? 300 + this.queue = + queue ?? + createDefaultQueue( + `${settings.writeKey}:event-queue`, + options?.retryQueue, + disablePersistance + ) + + const storageSetting = options?.storage + this._universalStorage = this.createStore( + disablePersistance, + storageSetting, + cookieOptions + ) + + this._user = + user ?? + new User( + { + persist: !disablePersistance, + storage: options?.storage, + // Any User specific options override everything else + ...options?.user, + }, + cookieOptions + ).load() + this._group = + group ?? + new Group( + { + persist: !disablePersistance, + storage: options?.storage, + // Any group specific options override everything else + ...options?.group, + }, + cookieOptions + ).load() + this.eventFactory = new EventFactory(this._user) + this.integrations = options?.integrations ?? {} + this.options = options ?? {} + autoBind(this) + } + + user = (): User => { + return this._user + } + + /** + * Creates the storage system based on the settings received + * @returns Storage + */ + private createStore( + disablePersistance: boolean, + storageSetting: InitOptions['storage'], + cookieOptions?: CookieOptions | undefined + ): UniversalStorage { + // DisablePersistance option overrides all, no storage will be used outside of memory even if specified + if (disablePersistance) { + return new UniversalStorage([new MemoryStorage()]) + } else { + if (storageSetting) { + if (isArrayOfStoreType(storageSetting)) { + // We will create the store with the priority for customer settings + return new UniversalStorage( + initializeStorages( + applyCookieOptions(storageSetting.stores, cookieOptions) + ) + ) + } + } + } + // We default to our multi storage with priority + return new UniversalStorage( + initializeStorages([ + StoreType.LocalStorage, + { + name: StoreType.Cookie, + settings: cookieOptions, + }, + StoreType.Memory, + ]) + ) + } + + get storage(): UniversalStorage { + return this._universalStorage + } + + async track(...args: EventParams): Promise { + const pageCtx = popPageContext(args) + const [name, data, opts, cb] = resolveArguments(...args) + + const segmentEvent = this.eventFactory.track( + name, + data as EventProperties, + opts, + this.integrations, + pageCtx + ) + + return this._dispatch(segmentEvent, cb).then((ctx) => { + this.emit('track', name, ctx.event.properties, ctx.event.options) + return ctx + }) + } + + async page(...args: PageParams): Promise { + const pageCtx = popPageContext(args) + const [category, page, properties, options, callback] = + resolvePageArguments(...args) + + const segmentEvent = this.eventFactory.page( + category, + page, + properties, + options, + this.integrations, + pageCtx + ) + + return this._dispatch(segmentEvent, callback).then((ctx) => { + this.emit('page', category, page, ctx.event.properties, ctx.event.options) + return ctx + }) + } + + async identify(...args: IdentifyParams): Promise { + const pageCtx = popPageContext(args) + const [id, _traits, options, callback] = resolveUserArguments(this._user)( + ...args + ) + + this._user.identify(id, _traits) + const segmentEvent = this.eventFactory.identify( + this._user.id(), + this._user.traits(), + options, + this.integrations, + pageCtx + ) + + return this._dispatch(segmentEvent, callback).then((ctx) => { + this.emit( + 'identify', + ctx.event.userId, + ctx.event.traits, + ctx.event.options + ) + return ctx + }) + } + + group(): Group + group(...args: GroupParams): Promise + group(...args: GroupParams): Promise | Group { + const pageCtx = popPageContext(args) + if (args.length === 0) { + return this._group + } + + const [id, _traits, options, callback] = resolveUserArguments(this._group)( + ...args + ) + + this._group.identify(id, _traits) + const groupId = this._group.id() + const groupTraits = this._group.traits() + + const segmentEvent = this.eventFactory.group( + groupId, + groupTraits, + options, + this.integrations, + pageCtx + ) + + return this._dispatch(segmentEvent, callback).then((ctx) => { + this.emit('group', ctx.event.groupId, ctx.event.traits, ctx.event.options) + return ctx + }) + } + + async alias(...args: AliasParams): Promise { + const pageCtx = popPageContext(args) + const [to, from, options, callback] = resolveAliasArguments(...args) + const segmentEvent = this.eventFactory.alias( + to, + from, + options, + this.integrations, + pageCtx + ) + return this._dispatch(segmentEvent, callback).then((ctx) => { + this.emit('alias', to, from, ctx.event.options) + return ctx + }) + } + + async screen(...args: PageParams): Promise { + const pageCtx = popPageContext(args) + const [category, page, properties, options, callback] = + resolvePageArguments(...args) + + const segmentEvent = this.eventFactory.screen( + category, + page, + properties, + options, + this.integrations, + pageCtx + ) + return this._dispatch(segmentEvent, callback).then((ctx) => { + this.emit( + 'screen', + category, + page, + ctx.event.properties, + ctx.event.options + ) + return ctx + }) + } + + async trackClick(...args: LinkArgs): Promise { + const autotrack = await import( + /* webpackChunkName: "auto-track" */ '../auto-track' + ) + return autotrack.link.call(this, ...args) + } + + async trackLink(...args: LinkArgs): Promise { + const autotrack = await import( + /* webpackChunkName: "auto-track" */ '../auto-track' + ) + return autotrack.link.call(this, ...args) + } + + async trackSubmit(...args: FormArgs): Promise { + const autotrack = await import( + /* webpackChunkName: "auto-track" */ '../auto-track' + ) + return autotrack.form.call(this, ...args) + } + + async trackForm(...args: FormArgs): Promise { + const autotrack = await import( + /* webpackChunkName: "auto-track" */ '../auto-track' + ) + return autotrack.form.call(this, ...args) + } + + async register(...plugins: Plugin[]): Promise { + const ctx = Context.system() + + const registrations = plugins.map((xt) => + this.queue.register(ctx, xt, this) + ) + await Promise.all(registrations) + + return ctx + } + + async deregister(...plugins: string[]): Promise { + const ctx = Context.system() + + const deregistrations = plugins.map((pl) => { + const plugin = this.queue.plugins.find((p) => p.name === pl) + if (plugin) { + return this.queue.deregister(ctx, plugin, this) + } else { + ctx.log('warn', `plugin ${pl} not found`) + } + }) + + await Promise.all(deregistrations) + + return ctx + } + + debug(toggle: boolean): Analytics { + // Make sure legacy ajs debug gets turned off if it was enabled before upgrading. + if (toggle === false && localStorage.getItem('debug')) { + localStorage.removeItem('debug') + } + this._debug = toggle + return this + } + + reset(): void { + this._user.reset() + this._group.reset() + this.emit('reset') + } + + timeout(timeout: number): void { + this.settings.timeout = timeout + } + + private async _dispatch( + event: SegmentEvent, + callback?: Callback + ): Promise { + const ctx = new Context(event) + if (isOffline() && !this.options.retryQueue) { + return ctx + } + return dispatch(ctx, this.queue, this, { + callback, + debug: this._debug, + timeout: this.settings.timeout, + }) + } + + async addSourceMiddleware(fn: MiddlewareFunction): Promise { + await this.queue.criticalTasks.run(async () => { + const { sourceMiddlewarePlugin } = await import( + /* webpackChunkName: "middleware" */ '../../plugins/middleware' + ) + + const integrations: Record = {} + this.queue.plugins.forEach((plugin) => { + if (plugin.type === 'destination') { + return (integrations[plugin.name] = true) + } + }) + + const plugin = sourceMiddlewarePlugin(fn, integrations) + await this.register(plugin) + }) + + return this + } + + /* TODO: This does not have to return a promise? */ + addDestinationMiddleware( + integrationName: string, + ...middlewares: DestinationMiddlewareFunction[] + ): Promise { + const legacyDestinations = this.queue.plugins.filter( + (xt) => xt.name.toLowerCase() === integrationName.toLowerCase() + ) as LegacyDestination[] + + legacyDestinations.forEach((destination) => { + destination.addMiddleware(...middlewares) + }) + return Promise.resolve(this) + } + + setAnonymousId(id?: string): ID { + return this._user.anonymousId(id) + } + + async queryString(query: string): Promise { + if (this.options.useQueryString === false) { + return [] + } + + const { queryString } = await import( + /* webpackChunkName: "queryString" */ '../query-string' + ) + return queryString(this, query) + } + + /** + * @deprecated This function does not register a destination plugin. + * + * Instantiates a legacy Analytics.js destination. + * + * This function does not register the destination as an Analytics.JS plugin, + * all the it does it to invoke the factory function back. + */ + use(legacyPluginFactory: (analytics: Analytics) => void): Analytics { + legacyPluginFactory(this) + return this + } + + async ready( + callback: Function = (res: Promise[]): Promise[] => res + ): Promise { + return Promise.all( + this.queue.plugins.map((i) => (i.ready ? i.ready() : Promise.resolve())) + ).then((res) => { + callback(res) + return res + }) + } + + // analytics-classic api + + noConflict(): Analytics { + console.warn(deprecationWarning) + setGlobalAnalytics(_analytics ?? this) + return this + } + + normalize(msg: SegmentEvent): SegmentEvent { + console.warn(deprecationWarning) + return this.eventFactory.normalize(msg) + } + + get failedInitializations(): string[] { + console.warn(deprecationWarning) + return this.queue.failedInitializations + } + + get VERSION(): string { + return version + } + + /* @deprecated - noop */ + async initialize( + _settings?: AnalyticsSettings, + _options?: InitOptions + ): Promise { + console.warn(deprecationWarning) + return Promise.resolve(this) + } + + init = this.initialize.bind(this) + + async pageview(url: string): Promise { + console.warn(deprecationWarning) + await this.page({ path: url }) + return this + } + + get plugins() { + console.warn(deprecationWarning) + // @ts-expect-error + return this._plugins ?? {} + } + + get Integrations() { + console.warn(deprecationWarning) + const integrations = this.queue.plugins + .filter((plugin) => plugin.type === 'destination') + .reduce((acc, plugin) => { + const name = `${plugin.name + .toLowerCase() + .replace('.', '') + .split(' ') + .join('-')}Integration` + + // @ts-expect-error + const integration = window[name] as + | (LegacyIntegration & { Integration?: LegacyIntegration }) + | undefined + + if (!integration) { + return acc + } + + const nested = integration.Integration // hack - Google Analytics function resides in the "Integration" field + if (nested) { + acc[plugin.name] = nested + return acc + } + + acc[plugin.name] = integration as LegacyIntegration + return acc + }, {} as Record) + + return integrations + } + + log = _stub + addIntegrationMiddleware = _stub + listeners = _stub + addEventListener = _stub + removeAllListeners = _stub + removeListener = _stub + removeEventListener = _stub + hasListeners = _stub + add = _stub + addIntegration = _stub + + // snippet function + // eslint-disable-next-line @typescript-eslint/no-explicit-any + push(args: any[]) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const an = this as any + const method = args.shift() + if (method) { + if (!an[method]) return + } + an[method].apply(this, args) + } +} diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index 876b0eb812..0f2b1b1cce 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -1,653 +1,3 @@ -import { - AliasParams, - DispatchedEvent, - EventParams, - GroupParams, - PageParams, - resolveAliasArguments, - resolveArguments, - resolvePageArguments, - resolveUserArguments, - IdentifyParams, -} from '../arguments-resolver' -import type { FormArgs, LinkArgs } from '../auto-track' -import { isOffline } from '../connection' -import { Context } from '../context' -import { dispatch, Emitter } from '@segment/analytics-core' -import { - Callback, - EventFactory, - Integrations, - Plan, - EventProperties, - SegmentEvent, -} from '../events' -import type { Plugin } from '../plugin' -import { EventQueue } from '../queue/event-queue' -import { Group, ID, User, UserOptions } from '../user' -import autoBind from '../../lib/bind-all' -import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' -import type { LegacyDestination } from '../../plugins/ajs-destination' -import type { - LegacyIntegration, - ClassicIntegrationSource, -} from '../../plugins/ajs-destination/types' -import type { - DestinationMiddlewareFunction, - MiddlewareFunction, -} from '../../plugins/middleware' -import { version } from '../../generated/version' -import { PriorityQueue } from '../../lib/priority-queue' -import { getGlobal } from '../../lib/get-global' -import { AnalyticsClassic, AnalyticsCore } from './interfaces' -import { HighEntropyHint } from '../../lib/client-hints/interfaces' -import type { LegacySettings } from '../../browser' -import { - CookieOptions, - MemoryStorage, - UniversalStorage, - StorageSettings, - StoreType, - applyCookieOptions, - initializeStorages, - isArrayOfStoreType, -} from '../storage' -import { PluginFactory } from '../../plugins/remote-loader' -import { setGlobalAnalytics } from '../../lib/global-analytics-helper' -import { popPageContext } from '../buffer' - -const deprecationWarning = - 'This is being deprecated and will be not be available in future releases of Analytics JS' - -// reference any pre-existing "analytics" object so a user can restore the reference -const global: any = getGlobal() -const _analytics = global?.analytics - -function createDefaultQueue( - name: string, - retryQueue = false, - disablePersistance = false -) { - const maxAttempts = retryQueue ? 10 : 1 - const priorityQueue = disablePersistance - ? new PriorityQueue(maxAttempts, []) - : new PersistedPriorityQueue(maxAttempts, name) - return new EventQueue(priorityQueue) -} - -export interface AnalyticsSettings { - writeKey: string - timeout?: number - plugins?: (Plugin | PluginFactory)[] - classicIntegrations?: ClassicIntegrationSource[] -} - -export interface InitOptions { - /** - * Disables storing any data on the client-side via cookies or localstorage. - * Defaults to `false`. - * - */ - disableClientPersistence?: boolean - /** - * Disables automatically converting ISO string event properties into Dates. - * ISO string to Date conversions occur right before sending events to a classic device mode integration, - * after any destination middleware have been ran. - * Defaults to `false`. - */ - disableAutoISOConversion?: boolean - initialPageview?: boolean - cookie?: CookieOptions - storage?: StorageSettings - user?: UserOptions - group?: UserOptions - integrations?: Integrations - plan?: Plan - retryQueue?: boolean - obfuscate?: boolean - /** - * This callback allows you to update/mutate CDN Settings. - * This is called directly after settings are fetched from the CDN. - */ - updateCDNSettings?: (settings: LegacySettings) => LegacySettings - /** - * Disables or sets constraints on processing of query string parameters - */ - useQueryString?: - | boolean - | { - aid?: RegExp - uid?: RegExp - } - /** - * Array of high entropy Client Hints to request. These may be rejected by the user agent - only required hints should be requested. - */ - highEntropyValuesClientHints?: HighEntropyHint[] - /** - * When using the snippet, this is the key that points to the global analytics instance (e.g. window.analytics). - * default: analytics - */ - globalAnalyticsKey?: string -} - -/* analytics-classic stubs */ -function _stub(this: never) { - console.warn(deprecationWarning) -} - -export class Analytics - extends Emitter - implements AnalyticsCore, AnalyticsClassic -{ - protected settings: AnalyticsSettings - private _user: User - private _group: Group - private eventFactory: EventFactory - private _debug = false - private _universalStorage: UniversalStorage - - initialized = false - integrations: Integrations - options: InitOptions - queue: EventQueue - - constructor( - settings: AnalyticsSettings, - options?: InitOptions, - queue?: EventQueue, - user?: User, - group?: Group - ) { - super() - const cookieOptions = options?.cookie - const disablePersistance = options?.disableClientPersistence ?? false - this.settings = settings - this.settings.timeout = this.settings.timeout ?? 300 - this.queue = - queue ?? - createDefaultQueue( - `${settings.writeKey}:event-queue`, - options?.retryQueue, - disablePersistance - ) - - const storageSetting = options?.storage - this._universalStorage = this.createStore( - disablePersistance, - storageSetting, - cookieOptions - ) - - this._user = - user ?? - new User( - { - persist: !disablePersistance, - storage: options?.storage, - // Any User specific options override everything else - ...options?.user, - }, - cookieOptions - ).load() - this._group = - group ?? - new Group( - { - persist: !disablePersistance, - storage: options?.storage, - // Any group specific options override everything else - ...options?.group, - }, - cookieOptions - ).load() - this.eventFactory = new EventFactory(this._user) - this.integrations = options?.integrations ?? {} - this.options = options ?? {} - autoBind(this) - } - - user = (): User => { - return this._user - } - - /** - * Creates the storage system based on the settings received - * @returns Storage - */ - private createStore( - disablePersistance: boolean, - storageSetting: InitOptions['storage'], - cookieOptions?: CookieOptions | undefined - ): UniversalStorage { - // DisablePersistance option overrides all, no storage will be used outside of memory even if specified - if (disablePersistance) { - return new UniversalStorage([new MemoryStorage()]) - } else { - if (storageSetting) { - if (isArrayOfStoreType(storageSetting)) { - // We will create the store with the priority for customer settings - return new UniversalStorage( - initializeStorages( - applyCookieOptions(storageSetting.stores, cookieOptions) - ) - ) - } - } - } - // We default to our multi storage with priority - return new UniversalStorage( - initializeStorages([ - StoreType.LocalStorage, - { - name: StoreType.Cookie, - settings: cookieOptions, - }, - StoreType.Memory, - ]) - ) - } - - get storage(): UniversalStorage { - return this._universalStorage - } - - async track(...args: EventParams): Promise { - const pageCtx = popPageContext(args) - const [name, data, opts, cb] = resolveArguments(...args) - - const segmentEvent = this.eventFactory.track( - name, - data as EventProperties, - opts, - this.integrations, - pageCtx - ) - - return this._dispatch(segmentEvent, cb).then((ctx) => { - this.emit('track', name, ctx.event.properties, ctx.event.options) - return ctx - }) - } - - async page(...args: PageParams): Promise { - const pageCtx = popPageContext(args) - const [category, page, properties, options, callback] = - resolvePageArguments(...args) - - const segmentEvent = this.eventFactory.page( - category, - page, - properties, - options, - this.integrations, - pageCtx - ) - - return this._dispatch(segmentEvent, callback).then((ctx) => { - this.emit('page', category, page, ctx.event.properties, ctx.event.options) - return ctx - }) - } - - async identify(...args: IdentifyParams): Promise { - const pageCtx = popPageContext(args) - const [id, _traits, options, callback] = resolveUserArguments(this._user)( - ...args - ) - - this._user.identify(id, _traits) - const segmentEvent = this.eventFactory.identify( - this._user.id(), - this._user.traits(), - options, - this.integrations, - pageCtx - ) - - return this._dispatch(segmentEvent, callback).then((ctx) => { - this.emit( - 'identify', - ctx.event.userId, - ctx.event.traits, - ctx.event.options - ) - return ctx - }) - } - - group(): Group - group(...args: GroupParams): Promise - group(...args: GroupParams): Promise | Group { - const pageCtx = popPageContext(args) - if (args.length === 0) { - return this._group - } - - const [id, _traits, options, callback] = resolveUserArguments(this._group)( - ...args - ) - - this._group.identify(id, _traits) - const groupId = this._group.id() - const groupTraits = this._group.traits() - - const segmentEvent = this.eventFactory.group( - groupId, - groupTraits, - options, - this.integrations, - pageCtx - ) - - return this._dispatch(segmentEvent, callback).then((ctx) => { - this.emit('group', ctx.event.groupId, ctx.event.traits, ctx.event.options) - return ctx - }) - } - - async alias(...args: AliasParams): Promise { - const pageCtx = popPageContext(args) - const [to, from, options, callback] = resolveAliasArguments(...args) - const segmentEvent = this.eventFactory.alias( - to, - from, - options, - this.integrations, - pageCtx - ) - return this._dispatch(segmentEvent, callback).then((ctx) => { - this.emit('alias', to, from, ctx.event.options) - return ctx - }) - } - - async screen(...args: PageParams): Promise { - const pageCtx = popPageContext(args) - const [category, page, properties, options, callback] = - resolvePageArguments(...args) - - const segmentEvent = this.eventFactory.screen( - category, - page, - properties, - options, - this.integrations, - pageCtx - ) - return this._dispatch(segmentEvent, callback).then((ctx) => { - this.emit( - 'screen', - category, - page, - ctx.event.properties, - ctx.event.options - ) - return ctx - }) - } - - async trackClick(...args: LinkArgs): Promise { - const autotrack = await import( - /* webpackChunkName: "auto-track" */ '../auto-track' - ) - return autotrack.link.call(this, ...args) - } - - async trackLink(...args: LinkArgs): Promise { - const autotrack = await import( - /* webpackChunkName: "auto-track" */ '../auto-track' - ) - return autotrack.link.call(this, ...args) - } - - async trackSubmit(...args: FormArgs): Promise { - const autotrack = await import( - /* webpackChunkName: "auto-track" */ '../auto-track' - ) - return autotrack.form.call(this, ...args) - } - - async trackForm(...args: FormArgs): Promise { - const autotrack = await import( - /* webpackChunkName: "auto-track" */ '../auto-track' - ) - return autotrack.form.call(this, ...args) - } - - async register(...plugins: Plugin[]): Promise { - const ctx = Context.system() - - const registrations = plugins.map((xt) => - this.queue.register(ctx, xt, this) - ) - await Promise.all(registrations) - - return ctx - } - - async deregister(...plugins: string[]): Promise { - const ctx = Context.system() - - const deregistrations = plugins.map((pl) => { - const plugin = this.queue.plugins.find((p) => p.name === pl) - if (plugin) { - return this.queue.deregister(ctx, plugin, this) - } else { - ctx.log('warn', `plugin ${pl} not found`) - } - }) - - await Promise.all(deregistrations) - - return ctx - } - - debug(toggle: boolean): Analytics { - // Make sure legacy ajs debug gets turned off if it was enabled before upgrading. - if (toggle === false && localStorage.getItem('debug')) { - localStorage.removeItem('debug') - } - this._debug = toggle - return this - } - - reset(): void { - this._user.reset() - this._group.reset() - this.emit('reset') - } - - timeout(timeout: number): void { - this.settings.timeout = timeout - } - - private async _dispatch( - event: SegmentEvent, - callback?: Callback - ): Promise { - const ctx = new Context(event) - if (isOffline() && !this.options.retryQueue) { - return ctx - } - return dispatch(ctx, this.queue, this, { - callback, - debug: this._debug, - timeout: this.settings.timeout, - }) - } - - async addSourceMiddleware(fn: MiddlewareFunction): Promise { - await this.queue.criticalTasks.run(async () => { - const { sourceMiddlewarePlugin } = await import( - /* webpackChunkName: "middleware" */ '../../plugins/middleware' - ) - - const integrations: Record = {} - this.queue.plugins.forEach((plugin) => { - if (plugin.type === 'destination') { - return (integrations[plugin.name] = true) - } - }) - - const plugin = sourceMiddlewarePlugin(fn, integrations) - await this.register(plugin) - }) - - return this - } - - /* TODO: This does not have to return a promise? */ - addDestinationMiddleware( - integrationName: string, - ...middlewares: DestinationMiddlewareFunction[] - ): Promise { - const legacyDestinations = this.queue.plugins.filter( - (xt) => xt.name.toLowerCase() === integrationName.toLowerCase() - ) as LegacyDestination[] - - legacyDestinations.forEach((destination) => { - destination.addMiddleware(...middlewares) - }) - return Promise.resolve(this) - } - - setAnonymousId(id?: string): ID { - return this._user.anonymousId(id) - } - - async queryString(query: string): Promise { - if (this.options.useQueryString === false) { - return [] - } - - const { queryString } = await import( - /* webpackChunkName: "queryString" */ '../query-string' - ) - return queryString(this, query) - } - - /** - * @deprecated This function does not register a destination plugin. - * - * Instantiates a legacy Analytics.js destination. - * - * This function does not register the destination as an Analytics.JS plugin, - * all the it does it to invoke the factory function back. - */ - use(legacyPluginFactory: (analytics: Analytics) => void): Analytics { - legacyPluginFactory(this) - return this - } - - async ready( - callback: Function = (res: Promise[]): Promise[] => res - ): Promise { - return Promise.all( - this.queue.plugins.map((i) => (i.ready ? i.ready() : Promise.resolve())) - ).then((res) => { - callback(res) - return res - }) - } - - // analytics-classic api - - noConflict(): Analytics { - console.warn(deprecationWarning) - setGlobalAnalytics(_analytics ?? this) - return this - } - - normalize(msg: SegmentEvent): SegmentEvent { - console.warn(deprecationWarning) - return this.eventFactory.normalize(msg) - } - - get failedInitializations(): string[] { - console.warn(deprecationWarning) - return this.queue.failedInitializations - } - - get VERSION(): string { - return version - } - - /* @deprecated - noop */ - async initialize( - _settings?: AnalyticsSettings, - _options?: InitOptions - ): Promise { - console.warn(deprecationWarning) - return Promise.resolve(this) - } - - init = this.initialize.bind(this) - - async pageview(url: string): Promise { - console.warn(deprecationWarning) - await this.page({ path: url }) - return this - } - - get plugins() { - console.warn(deprecationWarning) - // @ts-expect-error - return this._plugins ?? {} - } - - get Integrations() { - console.warn(deprecationWarning) - const integrations = this.queue.plugins - .filter((plugin) => plugin.type === 'destination') - .reduce((acc, plugin) => { - const name = `${plugin.name - .toLowerCase() - .replace('.', '') - .split(' ') - .join('-')}Integration` - - // @ts-expect-error - const integration = window[name] as - | (LegacyIntegration & { Integration?: LegacyIntegration }) - | undefined - - if (!integration) { - return acc - } - - const nested = integration.Integration // hack - Google Analytics function resides in the "Integration" field - if (nested) { - acc[plugin.name] = nested - return acc - } - - acc[plugin.name] = integration as LegacyIntegration - return acc - }, {} as Record) - - return integrations - } - - log = _stub - addIntegrationMiddleware = _stub - listeners = _stub - addEventListener = _stub - removeAllListeners = _stub - removeListener = _stub - removeEventListener = _stub - hasListeners = _stub - add = _stub - addIntegration = _stub - - // snippet function - // eslint-disable-next-line @typescript-eslint/no-explicit-any - push(args: any[]) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const an = this as any - const method = args.shift() - if (method) { - if (!an[method]) return - } - an[method].apply(this, args) - } -} +// this is a barrel file -- please do not put source code here, only exports. +export * from './analytics' +export * from './null-analytics' diff --git a/packages/browser/src/core/analytics/null-analytics.ts b/packages/browser/src/core/analytics/null-analytics.ts new file mode 100644 index 0000000000..2f7ae3c73e --- /dev/null +++ b/packages/browser/src/core/analytics/null-analytics.ts @@ -0,0 +1,11 @@ +import { Analytics } from './analytics' + +/** + * @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: 'DISABLED_ANALYICS' }, { disableClientPersistence: true }) + this.initialized = true + } +}