UNPKG

abmeter

Version:

ABMeter browser SDK — feature flags and A/B experiments with server-side pre-evaluated assignments

275 lines (247 loc) 9.39 kB
import { ApiError } from './api-error'; import { BATCH_SIZE, KEEPALIVE_BODY_LIMIT_BYTES, MAX_RETRY_QUEUE, MAX_SUBMIT_ATTEMPTS, } from './constants'; import type { ErrorCallback, Logger } from './config'; import type { HttpClient } from './http'; type Kind = 'exposure' | 'event'; interface QueueItem { kind: Kind; data: Record<string, unknown>; attempts: number; } export interface SubmitterOptions { http: HttpClient; flushIntervalMs: number; batchSize?: number; logger: Logger; errorCallback?: ErrorCallback; } // Main-thread queue: setInterval + batch-size trigger, drained hard on // visibilitychange→hidden and pagehide (never unload/beforeunload — those // break bfcache and don't fire on mobile). The hidden drain uses keepalive // fetch with a sendBeacon fallback; a refused beacon is logged and dropped — // ad-blockers exist, loss is expected, never fatal. export class AsyncSubmitter { private readonly http: HttpClient; private readonly flushIntervalMs: number; private readonly batchSize: number; private readonly logger: Logger; private readonly errorCallback?: ErrorCallback; private exposures: QueueItem[] = []; private events: QueueItem[] = []; private retryQueue: QueueItem[] = []; private timer: ReturnType<typeof setInterval> | null = null; private flushChain: Promise<void> = Promise.resolve(); private queuedFlush: Promise<void> | null = null; private readonly onVisibilityChange = (): void => { if (globalThis.document?.visibilityState === 'hidden') this.drainOnHide(); }; private readonly onPageHide = (): void => { this.drainOnHide(); }; constructor(options: SubmitterOptions) { this.http = options.http; this.flushIntervalMs = options.flushIntervalMs; this.batchSize = options.batchSize ?? BATCH_SIZE; this.logger = options.logger; this.errorCallback = options.errorCallback; } start(): void { if (this.timer === null) { this.timer = setInterval(() => void this.flush(), this.flushIntervalMs); } globalThis.document?.addEventListener('visibilitychange', this.onVisibilityChange); globalThis.window?.addEventListener('pagehide', this.onPageHide); } /** Detach timer + listeners without draining. reset() is the draining teardown. */ stop(): void { if (this.timer !== null) { clearInterval(this.timer); this.timer = null; } globalThis.document?.removeEventListener('visibilitychange', this.onVisibilityChange); globalThis.window?.removeEventListener('pagehide', this.onPageHide); } queueExposure(data: Record<string, unknown>): void { this.exposures.push({ kind: 'exposure', data, attempts: 0 }); this.flushIfFull(); } queueEvent(data: Record<string, unknown>): void { this.events.push({ kind: 'event', data, attempts: 0 }); this.flushIfFull(); } pending(): number { return this.exposures.length + this.events.length + this.retryQueue.length; } /** * Drain the queues over the network. One retry batch is attempted per flush; * items failing during this flush land in the retry queue and wait for the * next one, so a failing server can't spin this loop forever. * * Passes are serialized, never concurrent. flush() during a running pass * returns a promise for the NEXT pass — resolving early while a drain is * still in flight would break callers (SPA route changes, the canonical * app's pre-close barrier) that await flush() as proof nothing is queued. * Callers arriving in the same window share one queued pass. */ flush(): Promise<void> { if (this.queuedFlush) return this.queuedFlush; const pass = this.flushChain.then(() => { this.queuedFlush = null; return this.drain(); }); this.queuedFlush = pass; this.flushChain = pass.catch(() => undefined); return pass; } private async drain(): Promise<void> { await this.retryOneBatch(); while (this.exposures.length > 0 || this.events.length > 0) { if (this.exposures.length > 0) { await this.submitBatch('exposure', this.exposures.splice(0, this.batchSize)); } if (this.events.length > 0) { await this.submitBatch('event', this.events.splice(0, this.batchSize)); } } } /** * Drain fully and tear down. Terminates even against a failing server: every * failed batch either drops (validation, events on network error) or re-queues * with attempts++ and drops at MAX_SUBMIT_ATTEMPTS. */ async reset(options: { force?: boolean } = {}): Promise<void> { this.stop(); if (options.force) { const dropped = this.pending(); this.exposures = []; this.events = []; this.retryQueue = []; if (dropped > 0) this.logger(`reset(force): dropped ${dropped} queued items`); return; } while (this.pending() > 0) { await this.flush(); } } /** * Tab-death drain: chunked keepalive fetch (text/plain, CORS-simple) with a * sendBeacon fallback when the fetch fails. Fire-and-forget by necessity — * the page may be gone before any response arrives. */ drainOnHide(): void { const items = [...this.retryQueue, ...this.exposures, ...this.events]; this.retryQueue = []; this.exposures = []; this.events = []; for (const kind of ['exposure', 'event'] as const) { const rows = items.filter((item) => item.kind === kind).map((item) => item.data); for (const chunk of chunkForKeepalive(rows, this.batchSize)) { this.sendChunkOnHide(kind, chunk); } } } private sendChunkOnHide(kind: Kind, rows: unknown[]): void { const beaconFallback = (): void => { const accepted = kind === 'exposure' ? this.http.beaconExposures(rows) : this.http.beaconEvents(rows); if (!accepted) this.logger(`beacon refused; dropping ${rows.length} ${kind}s`); }; try { const post = kind === 'exposure' ? this.http.postExposures(rows, { keepalive: true }) : this.http.postEvents(rows, { keepalive: true }); post.catch(beaconFallback); } catch { beaconFallback(); } } private flushIfFull(): void { if (this.exposures.length + this.events.length >= this.batchSize) void this.flush(); } private async retryOneBatch(): Promise<void> { if (this.retryQueue.length === 0) return; const batch = this.retryQueue.splice(0, this.batchSize); const byKind = new Map<Kind, QueueItem[]>(); for (const item of batch) { const bucket = byKind.get(item.kind) ?? []; bucket.push(item); byKind.set(item.kind, bucket); } for (const [kind, items] of byKind) { await this.submitBatch(kind, items); } } private async submitBatch(kind: Kind, items: QueueItem[]): Promise<void> { const rows = items.map((item) => item.data); try { if (kind === 'exposure') { await this.http.postExposures(rows); } else { await this.http.postEvents(rows); } } catch (error) { this.handleSubmitError(kind, items, error); } } // The asymmetric retry contract shared with Ruby/Python: retryable API // error → re-queue (max attempts); validation/permanent → drop; unknown // network error → exposures re-queued (they feed the completeness gate), // events dropped. private handleSubmitError(kind: Kind, items: QueueItem[], error: unknown): void { if (error instanceof ApiError) { if (error.retryable) { this.logger(`retryable API error for ${items.length} ${kind}s`, error.message); this.requeue(items); } else if (error.partialFailure) { this.logger(`partial failure, dropping batch of ${items.length} ${kind}s`, error.message); } else { this.logger(`permanent API error, dropping ${items.length} ${kind}s`, error.message); } } else if (kind === 'exposure') { this.logger(`network error, re-queueing ${items.length} exposures`, error); this.requeue(items); } else { this.logger(`network error, dropping ${items.length} events`, error); } this.errorCallback?.(error); } private requeue(items: QueueItem[]): void { for (const item of items) { item.attempts += 1; if (item.attempts >= MAX_SUBMIT_ATTEMPTS) { this.logger(`max retries exceeded, dropping ${item.kind}`); } else if (this.retryQueue.length >= MAX_RETRY_QUEUE) { this.logger(`retry queue full, dropping ${item.kind}`); } else { this.retryQueue.push(item); } } } } // Split rows so each JSON body stays under the shared keepalive/beacon quota. // String length approximates bytes (payloads are ASCII-dominant); the 20% // headroom in the limit absorbs the difference. export function chunkForKeepalive(rows: unknown[], batchSize: number): unknown[][] { const chunks: unknown[][] = []; let current: unknown[] = []; let currentBytes = 2; // [] for (const row of rows) { const rowBytes = JSON.stringify(row).length + 1; const overflow = currentBytes + rowBytes > KEEPALIVE_BODY_LIMIT_BYTES || current.length >= batchSize; if (overflow && current.length > 0) { chunks.push(current); current = []; currentBytes = 2; } current.push(row); currentBytes += rowBytes; } if (current.length > 0) chunks.push(current); return chunks; }