import { getProcessEnv } from '../lib/get-process-env' import { setGlobalCDNUrl } from '../lib/parse-cdn' import { Analytics, AnalyticsSettings, NullAnalytics, InitOptions, } from '../core/analytics' import { Context } from '../core/context' import { Plan } from '../core/events' import { Plugin } from '../core/plugin' import { MetricsOptions } from '../core/stats/remote-metrics' import { mergedOptions } from '../lib/merged-options' import { createDeferred } from '@segment/analytics-generic-utils' import { envEnrichment } from '../plugins/env-enrichment' import { ddIntentSources } from '../plugins/dd-intent-sources' import { ddAnonymizeContextPlugin } from '../plugins/dd-anonymous-tracking' import { ddAnonymizeIPsPlugin } from '../plugins/dd-anonymize-ips' import { ddFormTracking } from '../plugins/dd-form-tracking' import { PluginFactory, remoteLoader, RemotePlugin, } from '../plugins/remote-loader' import type { RoutingRule } from '../plugins/routing-middleware' import { segmentio, SegmentioSettings } from '../plugins/segmentio' import { validation } from '../plugins/validation' import { AnalyticsBuffered, PreInitMethodCallBuffer, flushAnalyticsCallsInNewTask, flushAddSourceMiddleware, flushSetAnonymousID, flushOn, PreInitMethodCall, } from '../core/buffer' import { ClassicIntegrationSource } from '../plugins/ajs-destination/types' import { attachInspector } from '../core/inspector' import { Stats } from '../core/stats' import { setGlobalAnalyticsKey } from '../lib/global-analytics-helper' import { logger } from '../plugins/logger' export interface LegacyIntegrationConfiguration { /* @deprecated - This does not indicate browser types anymore */ type?: string versionSettings?: { version?: string override?: string componentTypes?: Array<'browser' | 'android' | 'ios' | 'server'> } bundlingStatus?: string /** * Consent settings for the integration */ consentSettings?: { /** * Consent categories for the integration * @example ["Analytics", "Advertising", "CAT001"] */ categories: string[] } // Segment.io specific retryQueue?: boolean // any extra unknown settings // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any } export interface LegacySettings { integrations: { [name: string]: LegacyIntegrationConfiguration } middlewareSettings?: { routingRules: RoutingRule[] } enabledMiddleware?: Record metrics?: MetricsOptions plan?: Plan legacyVideoPluginsEnabled?: boolean remotePlugins?: RemotePlugin[] /** * Top level consent settings */ consentSettings?: { /** * 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 } } export interface AnalyticsBrowserSettings extends AnalyticsSettings { /** * The settings for the Segment Source. * If provided, `AnalyticsBrowser` will not fetch remote settings * for the source. */ cdnSettings?: LegacySettings & Record /** * If provided, will override the default Segment CDN (https://cdn.segment.com) for this application. */ cdnURL?: string } /** In Segment analytics-next script, settings are fetched from their server * with configurations on e.g. which destinations are enabled. However, in our * case we have no stored settings and always want to send data to the same destination */ export function loadLegacySettings( writeKey: string, _?: string, apiHost?: string ): Promise { return Promise.resolve({ integrations: { 'Segment.io': { apiKey: writeKey, apiHost: apiHost ? `${apiHost}/api/v1` : undefined, }, }, }) } function hasLegacyDestinations(settings: LegacySettings): boolean { return ( getProcessEnv().NODE_ENV !== 'test' && // just one integration means segmentio Object.keys(settings.integrations).length > 1 ) } function hasTsubMiddleware(settings: LegacySettings): boolean { return ( getProcessEnv().NODE_ENV !== 'test' && (settings.middlewareSettings?.routingRules?.length ?? 0) > 0 ) } /** * With AJS classic, we allow users to call setAnonymousId before the library initialization. * This is important because some of the destinations will use the anonymousId during the initialization, * and if we set anonId afterwards, that wouldn’t impact the destination. * * Also Ensures events can be registered before library initialization. * This is important so users can register to 'initialize' and any events that may fire early during setup. */ function flushPreBuffer( analytics: Analytics, buffer: PreInitMethodCallBuffer ): void { flushSetAnonymousID(analytics, buffer) flushOn(analytics, buffer) } /** * Finish flushing buffer and cleanup. */ async function flushFinalBuffer( analytics: Analytics, buffer: PreInitMethodCallBuffer ): Promise { // Call popSnippetWindowBuffer before each flush task since there may be // analytics calls during async function calls. await flushAddSourceMiddleware(analytics, buffer) flushAnalyticsCallsInNewTask(analytics, buffer) // Clear buffer, just in case analytics is loaded twice; we don't want to fire events off again. buffer.clear() } async function registerPlugins( writeKey: string, legacySettings: LegacySettings, analytics: Analytics, opts: InitOptions, options: InitOptions, pluginLikes: (Plugin | PluginFactory)[] = [], legacyIntegrationSources: ClassicIntegrationSource[] ): Promise { const plugins = pluginLikes?.filter( (pluginLike) => typeof pluginLike === 'object' ) as Plugin[] const pluginSources = pluginLikes?.filter( (pluginLike) => typeof pluginLike === 'function' && typeof pluginLike.pluginName === 'string' ) as PluginFactory[] const tsubMiddleware = hasTsubMiddleware(legacySettings) ? await import( /* webpackChunkName: "tsub-middleware" */ '../plugins/routing-middleware' ).then((mod) => { return mod.tsubMiddleware( legacySettings.middlewareSettings!.routingRules ) }) : undefined const legacyDestinations = hasLegacyDestinations(legacySettings) || legacyIntegrationSources.length > 0 ? await import( /* webpackChunkName: "ajs-destination" */ '../plugins/ajs-destination' ).then((mod) => { return mod.ajsDestinations( writeKey, legacySettings, analytics.integrations, opts, tsubMiddleware, legacyIntegrationSources ) }) : [] if (legacySettings.legacyVideoPluginsEnabled) { await import( /* webpackChunkName: "legacyVideos" */ '../plugins/legacy-video-plugins' ).then((mod) => { return mod.loadLegacyVideoPlugins(analytics) }) } const schemaFilter = opts.plan?.track ? await import( /* webpackChunkName: "schemaFilter" */ '../plugins/schema-filter' ).then((mod) => { return mod.schemaFilter(opts.plan?.track, legacySettings) }) : undefined const mergedSettings = mergedOptions(legacySettings, options) const remotePlugins = await remoteLoader( legacySettings, analytics.integrations, mergedSettings, options.obfuscate, tsubMiddleware, pluginSources ).catch(() => []) const toRegister = [ validation, envEnrichment, ...plugins, ...legacyDestinations, ...remotePlugins, ] if (schemaFilter) { toRegister.push(schemaFilter) } /** Add custom dd plugins */ if (options.anonymous) { toRegister.push(ddAnonymizeContextPlugin()) } if (options.anonymizeIPs) { toRegister.push(ddAnonymizeIPsPlugin()) } if (options.formTracking) { toRegister.push(ddFormTracking(options.formTracking)) } const shouldIgnoreSegmentio = (opts.integrations?.All === false && !opts.integrations['Segment.io']) || (opts.integrations && opts.integrations['Segment.io'] === false) if (!shouldIgnoreSegmentio) { toRegister.push( await segmentio( analytics, mergedSettings['Segment.io'] as SegmentioSettings, legacySettings.integrations ) ) } const ctx = await analytics.register(...toRegister) // Register intent sources after other plugins // to make sure environment enrichment has finished if (options.intentSources) { await analytics.register(ddIntentSources(options)) } if ( Object.entries(legacySettings.enabledMiddleware ?? {}).some( ([, enabled]) => enabled ) ) { await import( /* webpackChunkName: "remoteMiddleware" */ '../plugins/remote-middleware' ).then(async ({ remoteMiddlewares }) => { const middleware = await remoteMiddlewares( ctx, legacySettings, options.obfuscate ) const promises = middleware.map((mdw) => analytics.addSourceMiddleware(mdw) ) return Promise.all(promises) }) } return ctx } async function loadAnalytics( settings: AnalyticsBrowserSettings, 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.anonymous) { if (options.formTracking) { logger.error('Cannot enable form tracking when using cookie-less') options.formTracking = undefined } if (options.intentSources) { logger.error('Cannot enable intent sources when using cookie-less') options.intentSources = undefined } } 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() if (settings.cdnURL) setGlobalCDNUrl(settings.cdnURL) if (options.initialPageview) { // capture the page context early, so it's always up-to-date preInitBuffer.push(new PreInitMethodCall('page', [])) } let legacySettings = settings.cdnSettings ?? (await loadLegacySettings( settings.writeKey, settings.cdnURL, options.apiHost )) if (options.updateCDNSettings) { 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 const opts: InitOptions = { retryQueue, cookie: { // We want to avoid using secure cookies in tests secure: process.env.NODE_ENV !== 'test', ...options.cookie, }, ...options, } if (!options.existingCookies) { opts.group = { ...opts.group, ddCookies: true } opts.user = { ...opts.user, ddCookies: true } } const analytics = new Analytics(settings, opts) attachInspector(analytics) const plugins = settings.plugins ?? [] const classicIntegrations = settings.classicIntegrations ?? [] Stats.initRemoteMetrics(legacySettings.metrics) // needs to be flushed before plugins are registered flushPreBuffer(analytics, preInitBuffer) const ctx = await registerPlugins( settings.writeKey, legacySettings, analytics, opts, options, plugins, classicIntegrations ) const search = window.location.search ?? '' const hash = window.location.hash ?? '' const term = search.length ? search : hash.replace(/(?=#).*(?=\?)/, '') if (term.includes('ajs_')) { await analytics.queryString(term).catch(console.error) } analytics.initialized = true analytics.emit('initialize', settings, options) await flushFinalBuffer(analytics, preInitBuffer) return [analytics, ctx] } /** * The public browser interface for Segment Analytics * * @example * ```ts * export const analytics = new AnalyticsBrowser() * analytics.load({ writeKey: 'foo' }) * ``` * @link https://github.com/segmentio/analytics-next/#readme */ export class AnalyticsBrowser extends AnalyticsBuffered { private _resolveLoadStart: ( settings: AnalyticsBrowserSettings, options: InitOptions ) => void constructor() { const { promise: loadStart, resolve: resolveLoadStart } = createDeferred>() super((buffer) => loadStart.then(([settings, options]) => loadAnalytics(settings, options, buffer) ) ) this._resolveLoadStart = (settings, options) => resolveLoadStart([settings, options]) } /** * Fully initialize an analytics instance, including: * * * Fetching settings from the segment CDN (by default). * * Fetching all remote destinations configured by the user (if applicable). * * Flushing buffered analytics events. * * Loading all middleware. * * Note:️ This method should only be called *once* in your application. * * @example * ```ts * export const analytics = new AnalyticsBrowser() * analytics.load({ writeKey: 'foo' }) * ``` */ load( settings: AnalyticsBrowserSettings, options: InitOptions = {} ): AnalyticsBrowser { this._resolveLoadStart(settings, options) return this } /** * Instantiates an object exposing Analytics methods. * * @example * ```ts * const ajs = AnalyticsBrowser.load({ writeKey: '' }) * * ajs.track("foo") * ... * ``` */ static load( settings: AnalyticsBrowserSettings, options: InitOptions = {} ): AnalyticsBrowser { return new AnalyticsBrowser().load(settings, options) } static standalone( writeKey: string, options?: InitOptions ): Promise { return AnalyticsBrowser.load({ writeKey }, options).then((res) => res[0]) } }