abmeter
Version:
ABMeter browser SDK — feature flags and A/B experiments with server-side pre-evaluated assignments
275 lines (247 loc) • 9.39 kB
text/typescript
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;
}