@segment/analytics-next
Version:
Analytics Next (aka Analytics 2.0) is the latest version of Segment’s JavaScript SDK - enabling you to send your data to any tool without having to learn, test, or use a new API every time.
415 lines (358 loc) • 11.6 kB
text/typescript
import { JSONObject } from '../../core/events'
import { Alias, Facade, Group, Identify, Page, Track } from '@segment/facade'
import { Analytics, InitOptions } from '../../core/analytics'
import { CDNSettings } from '../../browser'
import { isOffline, isOnline } from '../../core/connection'
import { Context, ContextCancelation } from '../../core/context'
import { isServer } from '../../core/environment'
import { InternalPluginWithAddMiddleware, Plugin } from '../../core/plugin'
import { attempt } from '@segment/analytics-core'
import { isPlanEventEnabled } from '../../lib/is-plan-event-enabled'
import { mergedOptions } from '../../lib/merged-options'
import { pWhile } from '../../lib/p-while'
import { PriorityQueue } from '../../lib/priority-queue'
import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted'
import {
applyDestinationMiddleware,
DestinationMiddlewareFunction,
} from '../middleware'
import {
buildIntegration,
loadIntegration,
resolveIntegrationNameFromSource,
resolveVersion,
unloadIntegration,
} from './loader'
import { LegacyIntegration, ClassicIntegrationSource } from './types'
import { isPlainObject } from '@segment/analytics-core'
import {
isDisabledIntegration as shouldSkipIntegration,
isInstallableIntegration,
} from './utils'
import { recordIntegrationMetric } from '../../core/stats/metric-helpers'
import { createDeferred } from '@segment/analytics-generic-utils'
import { IntegrationsInitOptions } from '../../browser/settings'
export type ClassType<T> = new (...args: unknown[]) => T
async function flushQueue(
xt: Plugin,
queue: PriorityQueue<Context>
): Promise<PriorityQueue<Context>> {
const failedQueue: Context[] = []
if (isOffline()) {
return queue
}
await pWhile(
() => queue.length > 0 && isOnline(),
async () => {
const ctx = queue.pop()
if (!ctx) {
return
}
const result = await attempt(ctx, xt)
const success = result instanceof Context
if (!success) {
failedQueue.push(ctx)
}
}
)
// re-add failed tasks
failedQueue.map((failed) => queue.pushWithBackoff(failed))
return queue
}
export class LegacyDestination implements InternalPluginWithAddMiddleware {
name: string
version: string
settings: JSONObject
options: InitOptions = {}
readonly type = 'destination'
middleware: DestinationMiddlewareFunction[] = []
private _ready: boolean | undefined
private _initialized: boolean | undefined
private onReady: Promise<unknown> | undefined
private initializePromise = createDeferred<boolean>()
private disableAutoISOConversion: boolean
integrationSource?: ClassicIntegrationSource
integration: LegacyIntegration | undefined
buffer: PriorityQueue<Context>
flushing = false
constructor(
name: string,
version: string,
writeKey: string,
settings: JSONObject = {},
options: InitOptions,
integrationSource?: ClassicIntegrationSource
) {
this.name = name
this.version = version
this.settings = { ...settings }
this.disableAutoISOConversion = options.disableAutoISOConversion || false
this.integrationSource = integrationSource
// AJS-Renderer sets an extraneous `type` setting that clobbers
// existing type defaults. We need to remove it if it's present
if (this.settings['type'] && this.settings['type'] === 'browser') {
delete this.settings['type']
}
this.initializePromise.promise.then(
(isInitialized) => (this._initialized = isInitialized),
() => {}
)
this.options = options
this.buffer = options.disableClientPersistence
? new PriorityQueue(4, [])
: new PersistedPriorityQueue(4, `${writeKey}:dest-${name}`)
this.scheduleFlush()
}
isLoaded(): boolean {
return !!this._ready
}
ready(): Promise<unknown> {
return this.initializePromise.promise.then(
() => this.onReady ?? Promise.resolve()
)
}
async load(ctx: Context, analyticsInstance: Analytics): Promise<void> {
if (this._ready || this.onReady !== undefined) {
return
}
try {
const integrationSource =
this.integrationSource ??
(await loadIntegration(
ctx,
this.name,
this.version,
this.options.obfuscate
))
this.integration = buildIntegration(
integrationSource,
this.settings,
analyticsInstance
)
} catch (error) {
recordIntegrationMetric(ctx, {
integrationName: this.name,
methodName: 'load',
type: 'classic',
didError: true,
})
throw error
}
this.onReady = new Promise((resolve) => {
const onReadyFn = (): void => {
this._ready = true
resolve(true)
}
this.integration!.once('ready', onReadyFn)
})
this.integration!.on('initialize', () => {
this.initializePromise.resolve(true)
})
try {
recordIntegrationMetric(ctx, {
integrationName: this.name,
methodName: 'initialize',
type: 'classic',
})
this.integration.initialize()
} catch (error) {
recordIntegrationMetric(ctx, {
integrationName: this.name,
methodName: 'initialize',
type: 'classic',
didError: true,
})
this.initializePromise.resolve(false)
throw error
}
}
unload(_ctx: Context, _analyticsInstance: Analytics): Promise<void> {
return unloadIntegration(this.name, this.version, this.options.obfuscate)
}
addMiddleware(...fn: DestinationMiddlewareFunction[]): void {
this.middleware = this.middleware.concat(...fn)
}
shouldBuffer(ctx: Context): boolean {
return (
// page events can't be buffered because of destinations that automatically add page views
ctx.event.type !== 'page' &&
(isOffline() || this._ready !== true || this._initialized !== true)
)
}
private async send<T extends Facade>(
ctx: Context,
clz: ClassType<T>,
eventType: 'track' | 'identify' | 'page' | 'alias' | 'group'
): Promise<Context> {
if (this.shouldBuffer(ctx)) {
this.buffer.push(ctx)
this.scheduleFlush()
return ctx
}
const plan = this.options?.plan?.track
const ev = ctx.event.event
if (plan && ev && this.name !== 'Segment.io') {
// events are always sent to segment (legacy behavior)
const planEvent = plan[ev]
if (!isPlanEventEnabled(plan, planEvent)) {
ctx.updateEvent('integrations', {
...ctx.event.integrations,
All: false,
'Segment.io': true,
})
ctx.cancel(
new ContextCancelation({
retry: false,
reason: `Event ${ev} disabled for integration ${this.name} in tracking plan`,
type: 'Dropped by plan',
})
)
} else {
ctx.updateEvent('integrations', {
...ctx.event.integrations,
...planEvent?.integrations,
})
}
if (planEvent?.enabled && planEvent?.integrations![this.name] === false) {
ctx.cancel(
new ContextCancelation({
retry: false,
reason: `Event ${ev} disabled for integration ${this.name} in tracking plan`,
type: 'Dropped by plan',
})
)
}
}
const afterMiddleware = await applyDestinationMiddleware(
this.name,
ctx.event,
this.middleware
)
if (afterMiddleware === null) {
return ctx
}
const event = new clz(afterMiddleware, {
traverse: !this.disableAutoISOConversion,
})
recordIntegrationMetric(ctx, {
integrationName: this.name,
methodName: eventType,
type: 'classic',
})
try {
if (this.integration) {
await this.integration!.invoke.call(this.integration, eventType, event)
}
} catch (err) {
recordIntegrationMetric(ctx, {
integrationName: this.name,
methodName: eventType,
type: 'classic',
didError: true,
})
throw err
}
return ctx
}
async track(ctx: Context): Promise<Context> {
return this.send(ctx, Track as ClassType<Track>, 'track')
}
async page(ctx: Context): Promise<Context> {
if (this.integration?._assumesPageview && !this._initialized) {
this.integration.initialize()
}
await this.initializePromise.promise
return this.send(ctx, Page as ClassType<Page>, 'page')
}
async identify(ctx: Context): Promise<Context> {
return this.send(ctx, Identify as ClassType<Identify>, 'identify')
}
async alias(ctx: Context): Promise<Context> {
return this.send(ctx, Alias as ClassType<Alias>, 'alias')
}
async group(ctx: Context): Promise<Context> {
return this.send(ctx, Group as ClassType<Group>, 'group')
}
private scheduleFlush(): void {
if (this.flushing) {
return
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
setTimeout(async () => {
if (isOffline() || this._ready !== true || this._initialized !== true) {
this.scheduleFlush()
return
}
this.flushing = true
this.buffer = await flushQueue(this, this.buffer)
this.flushing = false
if (this.buffer.todo > 0) {
this.scheduleFlush()
}
}, Math.random() * 5000)
}
}
export function ajsDestinations(
writeKey: string,
settings: CDNSettings,
integrations: IntegrationsInitOptions = {},
options: InitOptions = {},
routingMiddleware?: DestinationMiddlewareFunction,
legacyIntegrationSources?: ClassicIntegrationSource[]
): LegacyDestination[] {
if (isServer()) {
return []
}
if (settings.plan) {
options = options ?? {}
options.plan = settings.plan
}
const routingRules = settings.middlewareSettings?.routingRules ?? []
const remoteIntegrationsConfig = settings.integrations
const localIntegrationsConfig = options.integrations
// merged remote CDN settings with user provided options
const integrationOptions = mergedOptions(settings, options ?? {}) as Record<
string,
JSONObject
>
const adhocIntegrationSources = legacyIntegrationSources?.reduce(
(acc, integrationSource) => ({
...acc,
[resolveIntegrationNameFromSource(integrationSource)]: integrationSource,
}),
{} as Record<string, ClassicIntegrationSource>
)
const installableIntegrations = new Set([
// Remotely configured installable integrations
...Object.keys(remoteIntegrationsConfig).filter((name) =>
isInstallableIntegration(name, remoteIntegrationsConfig[name])
),
// Directly provided integration sources are only installable if settings for them are available
...Object.keys(adhocIntegrationSources || {}).filter(
(name) =>
isPlainObject(remoteIntegrationsConfig[name]) ||
isPlainObject(localIntegrationsConfig?.[name])
),
])
return Array.from(installableIntegrations)
.filter((name) => !shouldSkipIntegration(name, integrations))
.map((name) => {
const integrationSettings = remoteIntegrationsConfig[name]
const version = resolveVersion(integrationSettings)
const destination = new LegacyDestination(
name,
version,
writeKey,
integrationOptions[name],
options,
adhocIntegrationSources?.[name]
)
const routing = routingRules.filter(
(rule) => rule.destinationName === name
)
if (routing.length > 0 && routingMiddleware) {
destination.addMiddleware(routingMiddleware)
}
return destination
})
}