UNPKG

@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.

182 lines (156 loc) 4.7 kB
import { SegmentEvent } from '../../core/events' import { fetch } from '../../lib/fetch' import { onPageChange } from '../../lib/on-page-change' import { SegmentFacade } from '../../lib/to-facade' import { RateLimitError } from './ratelimit-error' import { Context } from '../../core/context' export type BatchingDispatchConfig = { size?: number timeout?: number maxRetries?: number keepalive?: boolean } const MAX_PAYLOAD_SIZE = 500 const MAX_KEEPALIVE_SIZE = 64 function kilobytes(buffer: unknown): number { const size = encodeURI(JSON.stringify(buffer)).split(/%..|./).length - 1 return size / 1024 } /** * Checks if the payload is over or close to * the maximum payload size allowed by tracking * API. */ function approachingTrackingAPILimit(buffer: unknown): boolean { return kilobytes(buffer) >= MAX_PAYLOAD_SIZE - 50 } /** * Checks if payload is over or approaching the limit for keepalive * requests. If keepalive is enabled we want to avoid * going over this to prevent data loss. */ function passedKeepaliveLimit(buffer: unknown): boolean { return kilobytes(buffer) >= MAX_KEEPALIVE_SIZE - 10 } function chunks(batch: object[]): Array<object[]> { const result: object[][] = [] let index = 0 batch.forEach((item) => { const size = kilobytes(result[index]) if (size >= 64) { index++ } if (result[index]) { result[index].push(item) } else { result[index] = [item] } }) return result } export default function batch( apiHost: string, config?: BatchingDispatchConfig ) { let buffer: object[] = [] let pageUnloaded = false const limit = config?.size ?? 10 const timeout = config?.timeout ?? 5000 let rateLimitTimeout = 0 function sendBatch(batch: object[]) { if (batch.length === 0) { return } const writeKey = (batch[0] as SegmentEvent)?.writeKey // Remove sentAt from every event as batching only needs a single timestamp const updatedBatch = batch.map((event) => { const { sentAt, ...newEvent } = event as SegmentEvent return newEvent }) return fetch(`https://${apiHost}/b`, { keepalive: config?.keepalive || pageUnloaded, headers: { 'Content-Type': 'text/plain', }, method: 'post', body: JSON.stringify({ writeKey, batch: updatedBatch, sentAt: new Date().toISOString(), }), }).then((res) => { if (res.status >= 500) { throw new Error(`Bad response from server: ${res.status}`) } if (res.status === 429) { const retryTimeoutStringSecs = res.headers?.get('x-ratelimit-reset') const retryTimeoutMS = typeof retryTimeoutStringSecs == 'string' ? parseInt(retryTimeoutStringSecs) * 1000 : timeout throw new RateLimitError( `Rate limit exceeded: ${res.status}`, retryTimeoutMS ) } }) } async function flush(attempt = 1): Promise<unknown> { if (buffer.length) { const batch = buffer buffer = [] return sendBatch(batch)?.catch((error) => { const ctx = Context.system() ctx.log('error', 'Error sending batch', error) if (attempt <= (config?.maxRetries ?? 10)) { if (error.name === 'RateLimitError') { rateLimitTimeout = error.retryTimeout } buffer.push(...batch) buffer.map((event) => { if ('_metadata' in event) { const segmentEvent = event as ReturnType<SegmentFacade['json']> segmentEvent._metadata = { ...segmentEvent._metadata, retryCount: attempt, } } }) scheduleFlush(attempt + 1) } }) } } let schedule: NodeJS.Timeout | undefined function scheduleFlush(attempt = 1): void { if (schedule) { return } schedule = setTimeout( () => { schedule = undefined flush(attempt).catch(console.error) }, rateLimitTimeout ? rateLimitTimeout : timeout ) rateLimitTimeout = 0 } onPageChange((unloaded) => { pageUnloaded = unloaded if (pageUnloaded && buffer.length) { const reqs = chunks(buffer).map(sendBatch) Promise.all(reqs).catch(console.error) } }) async function dispatch(_url: string, body: object): Promise<unknown> { buffer.push(body) const bufferOverflow = buffer.length >= limit || approachingTrackingAPILimit(buffer) || (config?.keepalive && passedKeepaliveLimit(buffer)) return bufferOverflow || pageUnloaded ? flush() : scheduleFlush() } return { dispatch, } }