UNPKG

@segment/analytics-core

Version:

This package represents core 'shared' functionality that is shared by analytics packages. This is not designed to be used directly, but internal to analytics-node and analytics-browser.

330 lines (274 loc) 8.89 kB
import { CoreAnalytics } from '../analytics' import { groupBy } from '../utils/group-by' import { ON_REMOVE_FROM_FUTURE, PriorityQueue } from '../priority-queue' import { CoreContext, ContextCancelation } from '../context' import { Emitter } from '@segment/analytics-generic-utils' import { IntegrationsOptions } from '../events/interfaces' import { CorePlugin } from '../plugins' import { createTaskGroup, TaskGroup } from '../task/task-group' import { attempt, ensure } from './delivery' export type EventQueueEmitterContract<Ctx extends CoreContext> = { message_delivered: [ctx: Ctx] message_enriched: [ctx: Ctx, plugin: CorePlugin<Ctx>] delivery_success: [ctx: Ctx] delivery_retry: [ctx: Ctx] delivery_failure: [ctx: Ctx, err: Ctx | Error | ContextCancelation] flush: [ctx: Ctx, delivered: boolean] initialization_failure: [CorePlugin<Ctx>] } export abstract class CoreEventQueue< Ctx extends CoreContext = CoreContext, Plugin extends CorePlugin<Ctx> = CorePlugin<Ctx> > extends Emitter<EventQueueEmitterContract<Ctx>> { /** * All event deliveries get suspended until all the tasks in this task group are complete. * For example: a middleware that augments the event object should be loaded safely as a * critical task, this way, event queue will wait for it to be ready before sending events. * * This applies to all the events already in the queue, and the upcoming ones */ criticalTasks: TaskGroup = createTaskGroup() queue: PriorityQueue<Ctx> plugins: Plugin[] = [] failedInitializations: string[] = [] private flushing = false constructor(priorityQueue: PriorityQueue<Ctx>) { super() this.queue = priorityQueue this.queue.on(ON_REMOVE_FROM_FUTURE, () => { this.scheduleFlush(0) }) } async register( ctx: Ctx, plugin: Plugin, instance: CoreAnalytics ): Promise<void> { this.plugins.push(plugin) const handleLoadError = (err: any) => { this.failedInitializations.push(plugin.name) this.emit('initialization_failure', plugin) console.warn(plugin.name, err) ctx.log('warn', 'Failed to load destination', { plugin: plugin.name, error: err, }) // Filter out the failed plugin by excluding it from the list this.plugins = this.plugins.filter((p) => p !== plugin) } if (plugin.type === 'destination' && plugin.name !== 'Segment.io') { plugin.load(ctx, instance).catch(handleLoadError) } else { // for non-destinations plugins, we do need to wait for them to load // reminder: action destinations can require plugins that are not of type "destination". // For example, GA4 loads a type 'before' plugins and addition to a type 'destination' plugin try { await plugin.load(ctx, instance) } catch (err) { handleLoadError(err) } } } async deregister( ctx: Ctx, plugin: CorePlugin<Ctx>, instance: CoreAnalytics ): Promise<void> { try { if (plugin.unload) { await Promise.resolve(plugin.unload(ctx, instance)) } this.plugins = this.plugins.filter((p) => p.name !== plugin.name) } catch (e) { ctx.log('warn', 'Failed to unload destination', { plugin: plugin.name, error: e, }) } } async dispatch(ctx: Ctx): Promise<Ctx> { ctx.log('debug', 'Dispatching') ctx.stats.increment('message_dispatched') this.queue.push(ctx) const willDeliver = this.subscribeToDelivery(ctx) this.scheduleFlush(0) return willDeliver } private async subscribeToDelivery(ctx: Ctx): Promise<Ctx> { return new Promise((resolve) => { const onDeliver = (flushed: Ctx, delivered: boolean): void => { if (flushed.isSame(ctx)) { this.off('flush', onDeliver) if (delivered) { resolve(flushed) } else { resolve(flushed) } } } this.on('flush', onDeliver) }) } async dispatchSingle(ctx: Ctx): Promise<Ctx> { ctx.log('debug', 'Dispatching') ctx.stats.increment('message_dispatched') this.queue.updateAttempts(ctx) ctx.attempts = 1 return this.deliver(ctx).catch((err) => { const accepted = this.enqueuRetry(err, ctx) if (!accepted) { ctx.setFailedDelivery({ reason: err }) return ctx } return this.subscribeToDelivery(ctx) }) } isEmpty(): boolean { return this.queue.length === 0 } private scheduleFlush(timeout = 500): void { if (this.flushing) { return } this.flushing = true setTimeout(() => { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.flush().then(() => { setTimeout(() => { this.flushing = false if (this.queue.length) { this.scheduleFlush(0) } }, 0) }) }, timeout) } private async deliver(ctx: Ctx): Promise<Ctx> { await this.criticalTasks.done() const start = Date.now() try { ctx = await this.flushOne(ctx) const done = Date.now() - start this.emit('delivery_success', ctx) ctx.stats.gauge('delivered', done) ctx.log('debug', 'Delivered', ctx.event) return ctx } catch (err: any) { const error = err as Ctx | Error | ContextCancelation ctx.log('error', 'Failed to deliver', error) this.emit('delivery_failure', ctx, error) ctx.stats.increment('delivery_failed') throw err } } private enqueuRetry(err: Error, ctx: Ctx): boolean { const retriable = !(err instanceof ContextCancelation) || err.retry if (!retriable) { return false } return this.queue.pushWithBackoff(ctx) } async flush(): Promise<Ctx[]> { if (this.queue.length === 0) { return [] } let ctx = this.queue.pop() if (!ctx) { return [] } ctx.attempts = this.queue.getAttempts(ctx) try { ctx = await this.deliver(ctx) this.emit('flush', ctx, true) } catch (err: any) { const accepted = this.enqueuRetry(err, ctx) if (!accepted) { ctx.setFailedDelivery({ reason: err }) this.emit('flush', ctx, false) } return [] } return [ctx] } private isReady(): boolean { // return this.plugins.every((p) => p.isLoaded()) // should we wait for every plugin to load? return true } private availableExtensions(denyList: IntegrationsOptions) { const available = this.plugins.filter((p) => { // Only filter out destination plugins or the Segment.io plugin if (p.type !== 'destination' && p.name !== 'Segment.io') { return true } let alternativeNameMatch: boolean | Record<string, unknown> | undefined = undefined p.alternativeNames?.forEach((name) => { if (denyList[name] !== undefined) { alternativeNameMatch = denyList[name] } }) // Explicit integration option takes precedence, `All: false` does not apply to Segment.io return ( denyList[p.name] ?? alternativeNameMatch ?? (p.name === 'Segment.io' ? true : denyList.All) !== false ) }) const { before = [], enrichment = [], destination = [], after = [], } = groupBy(available, 'type') return { before, enrichment, destinations: destination, after, } } private async flushOne(ctx: Ctx): Promise<Ctx> { if (!this.isReady()) { throw new Error('Not ready') } if (ctx.attempts > 1) { this.emit('delivery_retry', ctx) } const { before, enrichment } = this.availableExtensions( ctx.event.integrations ?? {} ) for (const beforeWare of before) { const temp = await ensure(ctx, beforeWare) if (temp instanceof CoreContext) { ctx = temp } this.emit('message_enriched', ctx, beforeWare) } for (const enrichmentWare of enrichment) { const temp = await attempt(ctx, enrichmentWare) if (temp instanceof CoreContext) { ctx = temp } this.emit('message_enriched', ctx, enrichmentWare) } // Enrichment and before plugins can re-arrange the deny list dynamically // so we need to pluck them at the end const { destinations, after } = this.availableExtensions( ctx.event.integrations ?? {} ) await new Promise((resolve, reject) => { setTimeout(() => { const attempts = destinations.map((destination) => attempt(ctx, destination) ) Promise.all(attempts).then(resolve).catch(reject) }, 0) }) ctx.stats.increment('message_delivered') this.emit('message_delivered', ctx) const afterCalls = after.map((after) => attempt(ctx, after)) await Promise.all(afterCalls) return ctx } }