From 80248cf89944385786bd1c7a7e35a56f0e0eab86 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 5 Mar 2026 14:23:07 +0100 Subject: [PATCH 1/7] feat(browser-sdk): add persistent bulk queue for batched events Queue user/company/check/prompt events to /bulk with retries and bounded localStorage persistence; keep track() on direct /event path to preserve response semantics. --- packages/browser-sdk/package.json | 2 +- packages/browser-sdk/src/bulkQueue.ts | 336 ++++++++++++++++++ packages/browser-sdk/src/client.ts | 96 ++++- packages/browser-sdk/src/config.ts | 4 + packages/browser-sdk/src/feedback/feedback.ts | 11 + packages/browser-sdk/src/flag/flags.ts | 34 +- packages/browser-sdk/test/bulkQueue.test.ts | 151 ++++++++ packages/browser-sdk/test/client.test.ts | 72 +++- .../test/e2e/acceptance.browser.spec.ts | 63 ++-- .../test/e2e/feedback-widget.browser.spec.ts | 4 +- packages/browser-sdk/test/init.test.ts | 13 +- packages/browser-sdk/test/mocks/handlers.ts | 53 +++ packages/browser-sdk/test/usage.test.ts | 142 +++++--- .../openfeature-browser-provider/package.json | 4 +- packages/react-native-sdk/package.json | 4 +- packages/react-sdk/package.json | 4 +- packages/vue-sdk/package.json | 4 +- yarn.lock | 24 +- 18 files changed, 871 insertions(+), 150 deletions(-) create mode 100644 packages/browser-sdk/src/bulkQueue.ts create mode 100644 packages/browser-sdk/test/bulkQueue.test.ts diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index 61577760..b559b900 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/browser-sdk", - "version": "1.4.3", + "version": "1.4.4", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/browser-sdk/src/bulkQueue.ts b/packages/browser-sdk/src/bulkQueue.ts new file mode 100644 index 00000000..8e1be50b --- /dev/null +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -0,0 +1,336 @@ +import { + BULK_QUEUE_FLUSH_DELAY_MS, + BULK_QUEUE_MAX_SIZE, + BULK_QUEUE_RETRY_BASE_DELAY_MS, + BULK_QUEUE_RETRY_MAX_DELAY_MS, +} from "./config"; +import { Logger } from "./logger"; +import { getDefaultStorageAdapter, StorageAdapter } from "./storage"; + +const BULK_QUEUE_STORAGE_KEY = "__reflag_bulk_queue_v1"; +const WARN_AFTER_CONSECUTIVE_FAILURES = 10; +const WARN_AFTER_FAILURE_MS = 5 * 60 * 1000; +const WARN_THROTTLE_MS = 15 * 60 * 1000; +const DROP_ERROR_THROTTLE_MS = 15 * 60 * 1000; + +type PayloadContext = { + active?: boolean; +}; + +export type BulkEvent = + | { + type: "company"; + companyId: string; + userId?: string; + attributes?: Record; + context?: PayloadContext; + } + | { + type: "user"; + userId: string; + attributes?: Record; + context?: PayloadContext; + } + | { + type: "event"; + event: string; + companyId?: string; + userId: string; + attributes?: Record; + context?: PayloadContext; + } + | { + type: "feature-flag-event"; + action: "check-is-enabled" | "check-config"; + key: string; + targetingVersion?: number; + evalResult?: boolean | { key: string; payload: any }; + evalContext?: Record; + evalRuleResults?: boolean[]; + evalMissingFields?: string[]; + } + | { + type: "prompt-event"; + action: "received" | "shown" | "dismissed"; + featureId: string; + promptId: string; + userId: string; + promptedQuestion: string; + }; + +export type BulkQueueOptions = { + flushDelayMs?: number; + maxSize?: number; + retryBaseDelayMs?: number; + retryMaxDelayMs?: number; + storage?: StorageAdapter; + storageKey?: string; + logger?: Logger; +}; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isBulkEvent(value: unknown): value is BulkEvent { + if (!isObject(value) || typeof value.type !== "string") { + return false; + } + + if (value.type === "user") { + return typeof value.userId === "string"; + } + + if (value.type === "company") { + return typeof value.companyId === "string"; + } + + if (value.type === "event") { + return typeof value.userId === "string" && typeof value.event === "string"; + } + + if (value.type === "feature-flag-event") { + return ( + typeof value.key === "string" && + (value.action === "check-is-enabled" || value.action === "check-config") + ); + } + + if (value.type === "prompt-event") { + return ( + typeof value.featureId === "string" && + typeof value.promptId === "string" && + typeof value.userId === "string" && + typeof value.promptedQuestion === "string" && + (value.action === "received" || + value.action === "shown" || + value.action === "dismissed") + ); + } + + return false; +} + +export class BulkQueue { + private readonly flushDelayMs: number; + private readonly maxSize: number; + private readonly retryBaseDelayMs: number; + private readonly retryMaxDelayMs: number; + private readonly storageKey: string; + private readonly storage: StorageAdapter; + private readonly logger?: Logger; + private readonly sendBulk: (events: BulkEvent[]) => Promise; + + private queue: BulkEvent[] = []; + private timer: ReturnType | null = null; + private inFlight = false; + private retryCount = 0; + private consecutiveFailures = 0; + private firstFailureAt: number | null = null; + private lastWarnAt: number | null = null; + private lastDropErrorAt: number | null = null; + private totalDroppedEvents = 0; + private droppedSinceLastError = 0; + + private readonly initialized: Promise; + + constructor( + sendBulk: (events: BulkEvent[]) => Promise, + opts: BulkQueueOptions = {}, + ) { + this.sendBulk = sendBulk; + this.flushDelayMs = opts.flushDelayMs ?? BULK_QUEUE_FLUSH_DELAY_MS; + this.maxSize = opts.maxSize ?? BULK_QUEUE_MAX_SIZE; + this.retryBaseDelayMs = + opts.retryBaseDelayMs ?? BULK_QUEUE_RETRY_BASE_DELAY_MS; + this.retryMaxDelayMs = opts.retryMaxDelayMs ?? BULK_QUEUE_RETRY_MAX_DELAY_MS; + this.storageKey = opts.storageKey ?? BULK_QUEUE_STORAGE_KEY; + this.storage = opts.storage ?? getDefaultStorageAdapter(); + this.logger = opts.logger; + + this.initialized = this.loadFromStorage().then(() => { + if (this.queue.length > 0) { + this.schedule(this.flushDelayMs); + } + }); + } + + async enqueue(event: BulkEvent) { + await this.initialized; + this.queue.push(event); + + if (this.queue.length > this.maxSize) { + const removed = this.queue.length - this.maxSize; + this.queue = this.queue.slice(-this.maxSize); + this.totalDroppedEvents += removed; + this.droppedSinceLastError += removed; + + const now = Date.now(); + if ( + !this.lastDropErrorAt || + now - this.lastDropErrorAt >= DROP_ERROR_THROTTLE_MS + ) { + this.logger?.error("bulk queue dropped events due to max size", { + droppedEvents: this.droppedSinceLastError, + totalDroppedEvents: this.totalDroppedEvents, + queueSize: this.queue.length, + maxSize: this.maxSize, + }); + this.lastDropErrorAt = now; + this.droppedSinceLastError = 0; + } + } + + await this.saveToStorage(); + + if (this.queue.length >= this.maxSize) { + void this.flush(); + return; + } + + this.schedule(this.flushDelayMs); + } + + async flush() { + await this.initialized; + + if (this.inFlight || this.queue.length === 0) { + return; + } + + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + this.inFlight = true; + const batch = this.queue.slice(0, this.maxSize); + let nextDelayMs: number | null = null; + + try { + const res = await this.sendBulk(batch); + if (!res.ok) { + throw new Error(`unexpected status ${res.status}`); + } + + this.queue.splice(0, batch.length); + this.retryCount = 0; + if (this.firstFailureAt !== null && this.consecutiveFailures > 0) { + this.logger?.info("bulk delivery recovered", { + outageMs: Date.now() - this.firstFailureAt, + failedAttempts: this.consecutiveFailures, + }); + } + this.firstFailureAt = null; + this.consecutiveFailures = 0; + this.lastWarnAt = null; + await this.saveToStorage(); + nextDelayMs = this.flushDelayMs; + } catch (error) { + const now = Date.now(); + if (this.firstFailureAt === null) { + this.firstFailureAt = now; + } + this.consecutiveFailures += 1; + this.retryCount += 1; + const retryInMs = this.getRetryDelay(); + nextDelayMs = retryInMs; + this.logger?.info("bulk retry scheduled", { + retryInMs, + queueSize: this.queue.length, + consecutiveFailures: this.consecutiveFailures, + }); + + const outageMs = now - this.firstFailureAt; + const shouldWarn = + this.consecutiveFailures >= WARN_AFTER_CONSECUTIVE_FAILURES || + outageMs >= WARN_AFTER_FAILURE_MS; + const canWarnNow = + this.lastWarnAt === null || now - this.lastWarnAt >= WARN_THROTTLE_MS; + if (shouldWarn && canWarnNow) { + this.logger?.warn("bulk delivery degraded", { + consecutiveFailures: this.consecutiveFailures, + outageMs, + queueSize: this.queue.length, + retryInMs, + error, + }); + this.lastWarnAt = now; + } + this.schedule(retryInMs); + } finally { + this.inFlight = false; + } + + if ( + this.queue.length > 0 && + !this.timer && + !this.inFlight && + nextDelayMs !== null + ) { + this.schedule(nextDelayMs); + } + } + + async size() { + await this.initialized; + return this.queue.length; + } + + private getRetryDelay() { + const maxExponent = 6; + const exponent = Math.min(this.retryCount - 1, maxExponent); + return Math.min( + this.retryBaseDelayMs * 2 ** exponent, + this.retryMaxDelayMs, + ); + } + + private schedule(delayMs: number) { + if (this.timer || this.inFlight || this.queue.length === 0) { + return; + } + + if (delayMs <= 0) { + void this.flush(); + return; + } + + this.timer = setTimeout(() => { + this.timer = null; + void this.flush(); + }, delayMs); + } + + private async saveToStorage() { + try { + if (this.queue.length === 0 && this.storage.removeItem) { + await this.storage.removeItem(this.storageKey); + return; + } + await this.storage.setItem(this.storageKey, JSON.stringify(this.queue)); + } catch (error) { + this.logger?.warn("failed to persist bulk queue", error); + } + } + + private async loadFromStorage() { + try { + const raw = await this.storage.getItem(this.storageKey); + if (!raw) { + return; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error("invalid stored bulk queue"); + } + + this.queue = parsed.filter(isBulkEvent).slice(-this.maxSize); + } catch (error) { + this.logger?.warn("failed to restore bulk queue from storage", error); + this.queue = []; + await this.saveToStorage(); + } + } +} diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 84cd6667..aacfb28f 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -16,6 +16,7 @@ import { RawFlags, } from "./flag/flags"; import { ToolbarPosition } from "./ui/types"; +import { BulkEvent, BulkQueue } from "./bulkQueue"; import { API_BASE_URL, APP_BASE_URL, @@ -304,6 +305,38 @@ export type InitOptions = ReflagDeprecatedContext & { * Useful for React Native (AsyncStorage). */ storage?: StorageAdapter; + + /** + * Queue settings for tracking updates sent to `/bulk`. + * Applies to user/company updates, check events, and prompt events. + */ + trackingQueue?: { + /** + * Delay in milliseconds before flushing queued events. + * Lower values send sooner; slightly higher values batch better. + * Defaults to 200ms. + */ + flushDelayMs?: number; + + /** + * Maximum number of queued events retained locally. + * Oldest events are dropped when the cap is exceeded. + * Defaults to 100. + */ + maxSize?: number; + + /** + * Base retry delay in milliseconds after a failed bulk request. + * Defaults to 5000ms. + */ + retryBaseDelayMs?: number; + + /** + * Maximum retry delay in milliseconds after repeated failures. + * Defaults to 60000ms. + */ + retryMaxDelayMs?: number; + }; }; const defaultConfig: Config = { @@ -395,6 +428,7 @@ export class ReflagClient { private readonly autoFeedback: AutoFeedback | undefined; private autoFeedbackInit: Promise | undefined; private readonly flagsClient: FlagsClient; + private readonly bulkQueue: BulkQueue | undefined; public readonly logger: Logger; @@ -437,6 +471,22 @@ export class ReflagClient { sdkVersion: opts?.sdkVersion, credentials: opts?.credentials, }); + if (!this.config.offline && this.config.enableTracking) { + this.bulkQueue = new BulkQueue( + (events) => this.httpClient.post({ path: "/bulk", body: events }), + { + flushDelayMs: opts.trackingQueue?.flushDelayMs, + maxSize: opts.trackingQueue?.maxSize, + retryBaseDelayMs: opts.trackingQueue?.retryBaseDelayMs, + retryMaxDelayMs: opts.trackingQueue?.retryMaxDelayMs, + storage: opts.storage, + storageKey: `__reflag_bulk_queue_v1:${this.config.apiBaseUrl}:${this.publishableKey}`, + logger: this.logger, + }, + ); + } + + const bulkQueue = this.bulkQueue; this.flagsClient = new FlagsClient( this.httpClient, @@ -451,6 +501,9 @@ export class ReflagClient { fallbackFlags: opts.fallbackFlags, offline: this.config.offline, storage: opts.storage, + enqueueBulkEvent: bulkQueue + ? (event) => bulkQueue.enqueue(event) + : undefined, }, ); @@ -473,6 +526,7 @@ export class ReflagClient { String(this.context.user?.id), opts?.feedback?.ui?.position, opts?.feedback?.ui?.translations, + bulkQueue ? (event) => bulkQueue.enqueue(event) : undefined, ); } } @@ -541,6 +595,8 @@ export class ReflagClient { * **/ async stop() { + await this.bulkQueue?.flush(); + if (this.autoFeedback) { // ensure fully initialized before stopping await this.autoFeedbackInit; @@ -731,7 +787,10 @@ export class ReflagClient { * @param eventName The name of the event. * @param attributes Any attributes you want to attach to the event. */ - async track(eventName: string, attributes?: Record | null) { + async track( + eventName: string, + attributes?: Record | null, + ): Promise { if (!this.context.user) { this.logger.warn("'track' call ignored. No user context provided"); return; @@ -753,8 +812,8 @@ export class ReflagClient { if (this.context.company?.id) payload.companyId = String(this.context.company?.id); - const res = await this.httpClient.post({ path: `/event`, body: payload }); - this.logger.debug(`sent event`, res); + const res = await this.httpClient.post({ path: "/event", body: payload }); + this.logger.debug(`sent event`, payload); this.hooks.trigger("track", { eventName, @@ -1001,17 +1060,24 @@ export class ReflagClient { if (this.config.offline) { return; } + if (!this.config.enableTracking) { + return; + } + if (!this.bulkQueue) { + return; + } const { id, ...attributes } = this.context.user; - const payload: User = { + const payload: BulkEvent = { + type: "user", userId: String(id), attributes, }; - const res = await this.httpClient.post({ path: `/user`, body: payload }); - this.logger.debug(`sent user`, res); + await this.bulkQueue.enqueue(payload); + this.logger.debug(`queued user`, payload); this.hooks.trigger("user", this.context.user); - return res; + return; } /** @@ -1035,18 +1101,24 @@ export class ReflagClient { if (this.config.offline) { return; } + if (!this.config.enableTracking) { + return; + } + if (!this.bulkQueue) { + return; + } const { id, ...attributes } = this.context.company; - const payload: Company = { + const payload: BulkEvent = { + type: "company", userId: String(this.context.user.id), companyId: String(id), attributes, }; - - const res = await this.httpClient.post({ path: `/company`, body: payload }); - this.logger.debug(`sent company`, res); + await this.bulkQueue.enqueue(payload); + this.logger.debug(`queued company`, payload); this.hooks.trigger("company", this.context.company); - return res; + return; } private async updateAutoFeedbackUser(userId: string) { diff --git a/packages/browser-sdk/src/config.ts b/packages/browser-sdk/src/config.ts index 2a165653..3f2a8737 100644 --- a/packages/browser-sdk/src/config.ts +++ b/packages/browser-sdk/src/config.ts @@ -9,6 +9,10 @@ export const SDK_VERSION_HEADER_NAME = "reflag-sdk-version"; export const SDK_VERSION = `browser-sdk/${version}`; export const FLAG_EVENTS_PER_MIN = 1; export const FLAGS_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000; // expire entirely after 30 days +export const BULK_QUEUE_MAX_SIZE = 100; +export const BULK_QUEUE_FLUSH_DELAY_MS = 200; +export const BULK_QUEUE_RETRY_BASE_DELAY_MS = 5000; +export const BULK_QUEUE_RETRY_MAX_DELAY_MS = 60_000; export const IS_SERVER = typeof window === "undefined" || typeof document === "undefined"; diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index af2ae060..0e38b055 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -1,3 +1,4 @@ +import type { BulkEvent } from "../bulkQueue"; import { HttpClient } from "../httpClient"; import { Logger } from "../logger"; import { AblySSEChannel, openAblySSEChannel } from "../sse"; @@ -277,6 +278,7 @@ export class AutoFeedback { private userId: string, private position: Position = DEFAULT_POSITION, private feedbackTranslations: Partial = {}, + private enqueueBulkEvent?: (event: BulkEvent) => Promise, ) {} /** @@ -445,6 +447,15 @@ export class AutoFeedback { promptedQuestion: args.promptedQuestion, }; + if (this.enqueueBulkEvent) { + await this.enqueueBulkEvent({ + type: "prompt-event", + ...payload, + }); + this.logger.debug(`queued prompt event`, payload); + return; + } + const res = await this.httpClient.post({ path: `/feedback/prompt-events`, body: payload, diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 1a776f6a..65fbd807 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -1,5 +1,6 @@ import { deepEqual } from "fast-equals"; +import type { BulkEvent } from "../bulkQueue"; import { FLAG_EVENTS_PER_MIN, FLAGS_EXPIRE_MS } from "../config"; import { ReflagContext } from "../context"; import { HttpClient } from "../httpClient"; @@ -189,6 +190,7 @@ type FlagsClientOptions = Partial & { cache?: FlagCache; rateLimiter?: RateLimiter; storage?: StorageAdapter; + enqueueBulkEvent?: (event: BulkEvent) => Promise; }; /** @@ -208,6 +210,7 @@ export class FlagsClient { private fallbackFlags: FallbackFlags = {}; private storage: StorageAdapter; private refreshEvents: number[] = []; + private enqueueBulkEvent?: (event: BulkEvent) => Promise; private config: Config = DEFAULT_FLAGS_CONFIG; @@ -224,6 +227,7 @@ export class FlagsClient { rateLimiter, fallbackFlags, storage, + enqueueBulkEvent, ...config }: FlagsClientOptions = {}, ) { @@ -235,6 +239,7 @@ export class FlagsClient { this.logger = loggerWithPrefix(logger, "[Flags]"); this.rateLimiter = rateLimiter ?? new RateLimiter(FLAG_EVENTS_PER_MIN, this.logger); + this.enqueueBulkEvent = enqueueBulkEvent; this.storage = (cache ? undefined : storage) ?? getDefaultStorageAdapter(); this.cache = cache ?? @@ -359,14 +364,29 @@ export class FlagsClient { evalMissingFields: checkEvent.missingContextFields, }; - this.httpClient - .post({ - path: "features/events", - body: payload, - }) - .catch((e: any) => { - this.logger.warn(`failed to send flag check event`, e); + if (this.enqueueBulkEvent) { + this.enqueueBulkEvent({ + type: "feature-flag-event", + action: payload.action, + key: payload.key, + targetingVersion: payload.targetingVersion, + evalContext: payload.evalContext, + evalResult: payload.evalResult, + evalRuleResults: payload.evalRuleResults, + evalMissingFields: payload.evalMissingFields, + }).catch((e: any) => { + this.logger.warn(`failed to enqueue flag check event`, e); }); + } else { + this.httpClient + .post({ + path: "features/events", + body: payload, + }) + .catch((e: any) => { + this.logger.warn(`failed to send flag check event`, e); + }); + } this.logger.debug(`sent flag event`, payload); cb(); diff --git a/packages/browser-sdk/test/bulkQueue.test.ts b/packages/browser-sdk/test/bulkQueue.test.ts new file mode 100644 index 00000000..3086a4c9 --- /dev/null +++ b/packages/browser-sdk/test/bulkQueue.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BulkEvent, BulkQueue } from "../src/bulkQueue"; +import { StorageAdapter } from "../src/storage"; + +function createMemoryStorage(): StorageAdapter { + const store = new Map(); + + return { + getItem: async (key) => store.get(key) ?? null, + setItem: async (key, value) => { + store.set(key, value); + }, + removeItem: async (key) => { + store.delete(key); + }, + }; +} + +const userEvent: BulkEvent = { + type: "user", + userId: "u1", + attributes: { name: "User" }, +}; + +const companyEvent: BulkEvent = { + type: "company", + userId: "u1", + companyId: "c1", + attributes: { name: "Company" }, +}; + +const trackEvent: BulkEvent = { + type: "event", + userId: "u1", + companyId: "c1", + event: "clicked", + attributes: { source: "banner" }, +}; + +describe("BulkQueue", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("batches events and flushes after the delay", async () => { + const sendBulk = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockResolvedValue(new Response("", { status: 200 })); + const queue = new BulkQueue(sendBulk, { + flushDelayMs: 75, + storage: createMemoryStorage(), + }); + + await queue.enqueue(userEvent); + await queue.enqueue(companyEvent); + + expect(sendBulk).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(74); + expect(sendBulk).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(sendBulk).toHaveBeenCalledTimes(1); + expect(sendBulk).toHaveBeenCalledWith([userEvent, companyEvent]); + }); + + it("retries failed bulk requests later", async () => { + const sendBulk = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockRejectedValueOnce(new Error("network")) + .mockResolvedValue(new Response("", { status: 200 })); + const queue = new BulkQueue(sendBulk, { + flushDelayMs: 10, + retryBaseDelayMs: 20, + retryMaxDelayMs: 20, + storage: createMemoryStorage(), + }); + + await queue.enqueue(trackEvent); + + await vi.advanceTimersByTimeAsync(10); + expect(sendBulk).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(19); + expect(sendBulk).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(sendBulk).toHaveBeenCalledTimes(2); + expect(sendBulk).toHaveBeenNthCalledWith(2, [trackEvent]); + }); + + it("keeps only the newest events when max size is exceeded", async () => { + let resolveSend: ((value: Response) => void) | undefined; + const sendBulk = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockImplementation( + () => + new Promise((resolve) => { + resolveSend = resolve; + }), + ); + const queue = new BulkQueue(sendBulk, { + flushDelayMs: 10_000, + maxSize: 2, + storage: createMemoryStorage(), + }); + + await queue.enqueue(userEvent); + await queue.enqueue(companyEvent); + await queue.enqueue(trackEvent); + + expect(await queue.size()).toBe(2); + expect(sendBulk).toHaveBeenCalledTimes(1); + expect(sendBulk).toHaveBeenCalledWith([userEvent, companyEvent]); + + resolveSend?.(new Response("", { status: 200 })); + }); + + it("restores queued events from storage", async () => { + const storage = createMemoryStorage(); + const firstSend = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockResolvedValue(new Response("", { status: 200 })); + const firstQueue = new BulkQueue(firstSend, { + flushDelayMs: 10_000, + storage, + }); + + await firstQueue.enqueue(userEvent); + expect(await firstQueue.size()).toBe(1); + + const secondSend = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockResolvedValue(new Response("", { status: 200 })); + const secondQueue = new BulkQueue(secondSend, { + flushDelayMs: 10_000, + storage, + }); + + expect(await secondQueue.size()).toBe(1); + + await secondQueue.flush(); + expect(secondSend).toHaveBeenCalledWith([userEvent]); + }); +}); diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index bf1fe228..b227b0a1 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -14,16 +14,22 @@ describe("ReflagClient", () => { const flagClientSetContext = vi.spyOn(FlagsClient.prototype, "setContext"); beforeEach(() => { + localStorage.clear(); client = new ReflagClient({ publishableKey: "test-key", user: { id: "user1" }, company: { id: "company1" }, + trackingQueue: { + flushDelayMs: 0, + }, }); vi.clearAllMocks(); }); - afterEach(() => { + afterEach(async () => { + await client.stop(); + localStorage.clear(); vi.unstubAllGlobals(); }); @@ -35,13 +41,18 @@ describe("ReflagClient", () => { await client.updateUser(updatedUser); expect(client["context"].user).toEqual({ id: "user1", ...updatedUser }); - expect(httpClientPost).toHaveBeenCalledWith({ - path: "/user", - body: { - userId: "user1", - attributes: { name: updatedUser.name }, - }, - }); + await vi.waitFor(() => + expect(httpClientPost).toHaveBeenCalledWith({ + path: "/bulk", + body: [ + { + type: "user", + userId: "user1", + attributes: { name: updatedUser.name }, + }, + ], + }), + ); expect(flagClientSetContext).toHaveBeenCalledWith(client["context"]); }); }); @@ -57,14 +68,19 @@ describe("ReflagClient", () => { id: "company1", ...updatedCompany, }); - expect(httpClientPost).toHaveBeenCalledWith({ - path: "/company", - body: { - userId: "user1", - companyId: "company1", - attributes: { name: updatedCompany.name }, - }, - }); + await vi.waitFor(() => + expect(httpClientPost).toHaveBeenCalledWith({ + path: "/bulk", + body: [ + { + type: "company", + userId: "user1", + companyId: "company1", + attributes: { name: updatedCompany.name }, + }, + ], + }), + ); expect(flagClientSetContext).toHaveBeenCalledWith(client["context"]); }); }); @@ -79,6 +95,30 @@ describe("ReflagClient", () => { }); }); + describe("track", () => { + it("sends events directly and returns the delivery response", async () => { + const response = await client.track("test-event", { a: 1 }); + + expect(response?.ok).toBe(true); + expect(httpClientPost).toHaveBeenCalledWith({ + path: "/event", + body: { + userId: "user1", + companyId: "company1", + event: "test-event", + attributes: { a: 1 }, + }, + }); + + const bulkCalls = vi + .mocked(httpClientPost) + .mock.calls.filter( + ([request]) => (request as { path?: string }).path === "/bulk", + ); + expect(bulkCalls).toHaveLength(0); + }); + }); + describe("hooks integration", () => { it("on adds hooks appropriately, off removes them", async () => { const trackHook = vi.fn(); diff --git a/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts b/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts index 65acf0f6..92b51f49 100644 --- a/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts +++ b/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts @@ -9,6 +9,7 @@ test("Acceptance", async ({ page }) => { await page.goto("http://localhost:8001/test/e2e/empty.html"); const successfulRequests: string[] = []; + const bulkEvents: Record[] = []; // Mock API calls with assertions await page.route(`${API_BASE_URL}/features/evaluated*`, async (route) => { @@ -22,33 +23,14 @@ test("Acceptance", async ({ page }) => { }); }); - await page.route(`${API_BASE_URL}/user`, async (route) => { + await page.route(`${API_BASE_URL}/bulk`, async (route) => { expect(route.request().method()).toEqual("POST"); - expect(route.request().postDataJSON()).toMatchObject({ - userId: "foo", - attributes: { - name: "john doe", - }, - }); + const payload = route.request().postDataJSON(); + expect(Array.isArray(payload)).toBe(true); - successfulRequests.push("USER"); - await route.fulfill({ - status: 200, - body: JSON.stringify({ success: true }), - }); - }); + bulkEvents.push(...payload); - await page.route(`${API_BASE_URL}/company`, async (route) => { - expect(route.request().method()).toEqual("POST"); - expect(route.request().postDataJSON()).toMatchObject({ - userId: "foo", - companyId: "bar", - attributes: { - name: "bar corp", - }, - }); - - successfulRequests.push("COMPANY"); + successfulRequests.push("BULK"); await route.fulfill({ status: 200, body: JSON.stringify({ success: true }), @@ -61,9 +43,7 @@ test("Acceptance", async ({ page }) => { userId: "foo", companyId: "bar", event: "baz", - attributes: { - baz: true, - }, + attributes: { baz: true }, }); successfulRequests.push("EVENT"); @@ -119,12 +99,25 @@ test("Acceptance", async ({ page }) => { })() `); - // Assert all API requests were made - expect(successfulRequests).toEqual([ - "FLAGS", - "USER", - "COMPANY", - "EVENT", - "FEEDBACK", - ]); + await expect.poll(() => bulkEvents.length).toBeGreaterThanOrEqual(2); + expect(bulkEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "user", + userId: "foo", + attributes: { name: "john doe" }, + }), + expect.objectContaining({ + type: "company", + userId: "foo", + companyId: "bar", + attributes: { name: "bar corp" }, + }), + ]), + ); + + expect(successfulRequests).toContain("FLAGS"); + expect(successfulRequests).toContain("BULK"); + expect(successfulRequests).toContain("EVENT"); + expect(successfulRequests).toContain("FEEDBACK"); }); diff --git a/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts b/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts index 8f75fb0c..bd306420 100644 --- a/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts +++ b/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts @@ -29,7 +29,7 @@ async function getOpenedWidgetContainer( await page.goto("http://localhost:8001/test/e2e/empty.html"); // Mock API calls - await page.route(`${API_HOST}/user`, async (route) => { + await page.route(`${API_HOST}/bulk`, async (route) => { await route.fulfill({ status: 200 }); }); @@ -66,7 +66,7 @@ async function getGiveFeedbackPageContainer( await page.goto("http://localhost:8001/test/e2e/give-feedback-button.html"); // Mock API calls - await page.route(`${API_HOST}/user`, async (route) => { + await page.route(`${API_HOST}/bulk`, async (route) => { await route.fulfill({ status: 200 }); }); diff --git a/packages/browser-sdk/test/init.test.ts b/packages/browser-sdk/test/init.test.ts index b63a0067..aae137d2 100644 --- a/packages/browser-sdk/test/init.test.ts +++ b/packages/browser-sdk/test/init.test.ts @@ -1,5 +1,5 @@ import { DefaultBodyType, http, StrictRequest } from "msw"; -import { beforeEach, describe, expect, test, vi, vitest } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi, vitest } from "vitest"; import { ReflagClient } from "../src"; import { HttpClient } from "../src/httpClient"; @@ -17,9 +17,14 @@ const logger = { }; beforeEach(() => { + localStorage.clear(); vi.clearAllMocks(); }); +afterEach(() => { + localStorage.clear(); +}); + describe("init", () => { test("will accept setup with key and debug logger", async () => { const reflagInstance = new ReflagClient({ @@ -33,6 +38,7 @@ describe("init", () => { await reflagInstance.initialize(); expect(spyInit).toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalled(); + await reflagInstance.stop(); }); test("will accept setup with custom host", async () => { @@ -51,10 +57,12 @@ describe("init", () => { publishableKey: KEY, user: { id: "foo" }, apiBaseUrl: "https://example.com", + enableTracking: false, }); await reflagInstance.initialize(); expect(usedSpecialHost).toBe(true); + await reflagInstance.stop(); }); test("automatically does user/company tracking", async () => { @@ -70,6 +78,7 @@ describe("init", () => { expect(user).toHaveBeenCalled(); expect(company).toHaveBeenCalled(); + await reflagInstance.stop(); }); test("can disable tracking and auto. feedback surveys", async () => { @@ -88,6 +97,7 @@ describe("init", () => { await reflagInstance.track("test"); expect(post).not.toHaveBeenCalled(); + await reflagInstance.stop(); }); test("passes credentials correctly to httpClient", async () => { @@ -103,5 +113,6 @@ describe("init", () => { expect(reflagInstance["httpClient"]["fetchOptions"].credentials).toBe( credentials, ); + await reflagInstance.stop(); }); }); diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index 0b671133..bcf93ea3 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -79,6 +79,59 @@ export function getFlags({ } export const handlers = [ + http.post("https://front.reflag.com/bulk", async ({ request }) => { + if (!checkRequest(request)) return invalidReqResponse; + + const data = await request.json(); + if (!Array.isArray(data) || data.length === 0) { + return HttpResponse.error(); + } + + const valid = data.every((item) => { + if (typeof item !== "object" || item === null || !("type" in item)) { + return false; + } + const event = item as Record; + if (event.type === "user") { + return typeof event.userId === "string"; + } + if (event.type === "company") { + return typeof event.companyId === "string"; + } + if (event.type === "event") { + return ( + typeof event.userId === "string" && typeof event.event === "string" + ); + } + if (event.type === "feature-flag-event") { + return ( + typeof event.key === "string" && + (event.action === "check-is-enabled" || + event.action === "check-config") + ); + } + if (event.type === "prompt-event") { + return ( + typeof event.featureId === "string" && + typeof event.promptId === "string" && + typeof event.userId === "string" && + typeof event.promptedQuestion === "string" && + (event.action === "received" || + event.action === "shown" || + event.action === "dismissed") + ); + } + return false; + }); + + if (!valid) { + return HttpResponse.error(); + } + + return HttpResponse.json({ + success: true, + }); + }), http.post("https://front.reflag.com/user", async ({ request }) => { if (!checkRequest(request)) return invalidReqResponse; diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index 63507894..4fbbf380 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -48,6 +48,10 @@ afterEach(() => { server.resetHandlers(); }); +beforeEach(() => { + localStorage.clear(); +}); + describe("usage", () => { afterEach(() => { vi.clearAllMocks(); @@ -217,17 +221,27 @@ describe("feedback state management", () => { }); events = []; server.use( - http.post( - `${API_BASE_URL}/feedback/prompt-events`, - async ({ request }) => { - const body = await request.json(); - if (!(body && typeof body === "object" && "action" in body)) { - throw new Error("invalid request"); - } - events.push(String(body["action"])); - return HttpResponse.json({ success: true }); - }, - ), + http.post(`${API_BASE_URL}/bulk`, async ({ request }) => { + const body = await request.json(); + if (!Array.isArray(body)) { + throw new Error("invalid request"); + } + + body + .filter( + (event) => + event && + typeof event === "object" && + "type" in event && + event["type"] === "prompt-event" && + "action" in event, + ) + .forEach((event) => { + events.push(String(event["action"])); + }); + + return HttpResponse.json({ success: true }); + }), ); }); @@ -473,6 +487,9 @@ describe(`sends "check" events `, () => { publishableKey: KEY, user: { id: "uid" }, company: { id: "cid" }, + trackingQueue: { + flushDelayMs: 0, + }, }); await client.initialize(); @@ -494,25 +511,36 @@ describe(`sends "check" events `, () => { expect.any(Function), ); - expect(postSpy).toHaveBeenCalledWith({ - body: { - action: "check-is-enabled", - evalContext: { - company: { - id: "cid", - }, - other: {}, - user: { - id: "uid", - }, - }, - evalResult: true, - evalRuleResults: [false, true], - evalMissingFields: ["field1", "field2"], - key: "flagA", - targetingVersion: 1, - }, - path: "features/events", + await vi.waitFor(() => { + const bulkEvents = vi + .mocked(postSpy) + .mock.calls.filter(([request]) => request.path === "/bulk") + .flatMap(([request]) => + Array.isArray(request.body) ? request.body : [], + ); + + expect(bulkEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "feature-flag-event", + action: "check-is-enabled", + key: "flagA", + targetingVersion: 1, + evalContext: { + company: { + id: "cid", + }, + other: {}, + user: { + id: "uid", + }, + }, + evalResult: true, + evalRuleResults: [false, true], + evalMissingFields: ["field1", "field2"], + }), + ]), + ); }); }); @@ -522,6 +550,9 @@ describe(`sends "check" events `, () => { const client = new ReflagClient({ publishableKey: KEY, user: { id: "uid" }, + trackingQueue: { + flushDelayMs: 0, + }, }); await client.initialize(); @@ -530,26 +561,37 @@ describe(`sends "check" events `, () => { key: "gpt3", }); - expect(postSpy).toHaveBeenCalledWith({ - body: { - action: "check-config", - evalContext: { - company: undefined, - other: {}, - user: { - id: "uid", - }, - }, - evalResult: { - key: "gpt3", - payload: { model: "gpt-something", temperature: 0.5 }, - }, - evalRuleResults: [true, false, false], - evalMissingFields: ["field3"], - key: "flagB", - targetingVersion: 12, - }, - path: "features/events", + await vi.waitFor(() => { + const bulkEvents = vi + .mocked(postSpy) + .mock.calls.filter(([request]) => request.path === "/bulk") + .flatMap(([request]) => + Array.isArray(request.body) ? request.body : [], + ); + + expect(bulkEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", + key: "flagB", + targetingVersion: 12, + evalContext: { + company: undefined, + other: {}, + user: { + id: "uid", + }, + }, + evalResult: { + key: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + }, + evalRuleResults: [true, false, false], + evalMissingFields: ["field3"], + }), + ]), + ); }); }); diff --git a/packages/openfeature-browser-provider/package.json b/packages/openfeature-browser-provider/package.json index 475dbe6b..1b9f4b15 100644 --- a/packages/openfeature-browser-provider/package.json +++ b/packages/openfeature-browser-provider/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/openfeature-browser-provider", - "version": "1.3.1", + "version": "1.3.2", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { @@ -35,7 +35,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.4.1" + "@reflag/browser-sdk": "1.4.4" }, "devDependencies": { "@openfeature/core": "1.5.0", diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 85f1e2b8..f5e7d707 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/react-native-sdk", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "repository": { "type": "git", @@ -32,7 +32,7 @@ }, "dependencies": { "@react-native-async-storage/async-storage": "^2.2.0", - "@reflag/react-sdk": "1.4.3" + "@reflag/react-sdk": "1.4.4" }, "peerDependencies": { "react": "*", diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 803e2555..2a6575a5 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/react-sdk", - "version": "1.4.3", + "version": "1.4.4", "license": "MIT", "repository": { "type": "git", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.4.3" + "@reflag/browser-sdk": "1.4.4" }, "peerDependencies": { "react": "*", diff --git a/packages/vue-sdk/package.json b/packages/vue-sdk/package.json index 0878f69f..fb39999b 100644 --- a/packages/vue-sdk/package.json +++ b/packages/vue-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/vue-sdk", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "repository": { "type": "git", @@ -35,7 +35,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.4.1" + "@reflag/browser-sdk": "1.4.4" }, "peerDependencies": { "vue": "^3.0.0" diff --git a/yarn.lock b/yarn.lock index d52de854..b8d20bfa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5676,19 +5676,7 @@ __metadata: languageName: node linkType: hard -"@reflag/browser-sdk@npm:1.4.1": - version: 1.4.1 - resolution: "@reflag/browser-sdk@npm:1.4.1" - dependencies: - "@floating-ui/dom": "npm:^1.6.8" - fast-equals: "npm:^5.2.2" - js-cookie: "npm:^3.0.5" - preact: "npm:^10.22.1" - checksum: 10c0/6658b14329e9db49fa848b10665821bb770a36a0a7fa6ca1925c375c8397ba8c180acc1cb46431eba4bc802095f442e5a891d6d4aa2a56ac921bf734399c29a4 - languageName: node - linkType: hard - -"@reflag/browser-sdk@npm:1.4.3, @reflag/browser-sdk@workspace:packages/browser-sdk": +"@reflag/browser-sdk@npm:1.4.4, @reflag/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@reflag/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -5829,7 +5817,7 @@ __metadata: dependencies: "@openfeature/core": "npm:1.5.0" "@openfeature/web-sdk": "npm:^1.3.0" - "@reflag/browser-sdk": "npm:1.4.1" + "@reflag/browser-sdk": "npm:1.4.4" "@reflag/eslint-config": "npm:0.0.2" "@reflag/tsconfig": "npm:0.0.2" "@types/node": "npm:^22.12.0" @@ -5875,7 +5863,7 @@ __metadata: dependencies: "@react-native-async-storage/async-storage": "npm:^2.2.0" "@reflag/eslint-config": "npm:^0.0.2" - "@reflag/react-sdk": "npm:1.4.3" + "@reflag/react-sdk": "npm:1.4.4" "@reflag/tsconfig": "npm:^0.0.2" "@types/react": "npm:^19.0.12" eslint: "npm:^9.21.0" @@ -5887,11 +5875,11 @@ __metadata: languageName: unknown linkType: soft -"@reflag/react-sdk@npm:1.4.3, @reflag/react-sdk@workspace:^, @reflag/react-sdk@workspace:packages/react-sdk": +"@reflag/react-sdk@npm:1.4.4, @reflag/react-sdk@workspace:^, @reflag/react-sdk@workspace:packages/react-sdk": version: 0.0.0-use.local resolution: "@reflag/react-sdk@workspace:packages/react-sdk" dependencies: - "@reflag/browser-sdk": "npm:1.4.3" + "@reflag/browser-sdk": "npm:1.4.4" "@reflag/eslint-config": "npm:^0.0.2" "@reflag/tsconfig": "npm:^0.0.2" "@testing-library/react": "npm:^15.0.7" @@ -5951,7 +5939,7 @@ __metadata: version: 0.0.0-use.local resolution: "@reflag/vue-sdk@workspace:packages/vue-sdk" dependencies: - "@reflag/browser-sdk": "npm:1.4.1" + "@reflag/browser-sdk": "npm:1.4.4" "@reflag/eslint-config": "npm:^0.0.2" "@reflag/tsconfig": "npm:^0.0.2" "@types/jsdom": "npm:^21.1.6" From 6fdf521bccecdc2899f4c0420ab3c59ec89c2537 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 5 Mar 2026 15:43:03 +0100 Subject: [PATCH 2/7] refactor(browser-sdk): make bulk queue memory-only Remove localStorage persistence and storage options from BulkQueue; keep batching/retry behavior in-memory only and update tests/docs accordingly. --- packages/browser-sdk/src/bulkQueue.ts | 99 --------------------- packages/browser-sdk/src/client.ts | 3 +- packages/browser-sdk/test/bulkQueue.test.ts | 28 +----- 3 files changed, 4 insertions(+), 126 deletions(-) diff --git a/packages/browser-sdk/src/bulkQueue.ts b/packages/browser-sdk/src/bulkQueue.ts index 8e1be50b..540af3c3 100644 --- a/packages/browser-sdk/src/bulkQueue.ts +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -5,9 +5,6 @@ import { BULK_QUEUE_RETRY_MAX_DELAY_MS, } from "./config"; import { Logger } from "./logger"; -import { getDefaultStorageAdapter, StorageAdapter } from "./storage"; - -const BULK_QUEUE_STORAGE_KEY = "__reflag_bulk_queue_v1"; const WARN_AFTER_CONSECUTIVE_FAILURES = 10; const WARN_AFTER_FAILURE_MS = 5 * 60 * 1000; const WARN_THROTTLE_MS = 15 * 60 * 1000; @@ -63,61 +60,14 @@ export type BulkQueueOptions = { maxSize?: number; retryBaseDelayMs?: number; retryMaxDelayMs?: number; - storage?: StorageAdapter; - storageKey?: string; logger?: Logger; }; -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isBulkEvent(value: unknown): value is BulkEvent { - if (!isObject(value) || typeof value.type !== "string") { - return false; - } - - if (value.type === "user") { - return typeof value.userId === "string"; - } - - if (value.type === "company") { - return typeof value.companyId === "string"; - } - - if (value.type === "event") { - return typeof value.userId === "string" && typeof value.event === "string"; - } - - if (value.type === "feature-flag-event") { - return ( - typeof value.key === "string" && - (value.action === "check-is-enabled" || value.action === "check-config") - ); - } - - if (value.type === "prompt-event") { - return ( - typeof value.featureId === "string" && - typeof value.promptId === "string" && - typeof value.userId === "string" && - typeof value.promptedQuestion === "string" && - (value.action === "received" || - value.action === "shown" || - value.action === "dismissed") - ); - } - - return false; -} - export class BulkQueue { private readonly flushDelayMs: number; private readonly maxSize: number; private readonly retryBaseDelayMs: number; private readonly retryMaxDelayMs: number; - private readonly storageKey: string; - private readonly storage: StorageAdapter; private readonly logger?: Logger; private readonly sendBulk: (events: BulkEvent[]) => Promise; @@ -132,8 +82,6 @@ export class BulkQueue { private totalDroppedEvents = 0; private droppedSinceLastError = 0; - private readonly initialized: Promise; - constructor( sendBulk: (events: BulkEvent[]) => Promise, opts: BulkQueueOptions = {}, @@ -144,19 +92,10 @@ export class BulkQueue { this.retryBaseDelayMs = opts.retryBaseDelayMs ?? BULK_QUEUE_RETRY_BASE_DELAY_MS; this.retryMaxDelayMs = opts.retryMaxDelayMs ?? BULK_QUEUE_RETRY_MAX_DELAY_MS; - this.storageKey = opts.storageKey ?? BULK_QUEUE_STORAGE_KEY; - this.storage = opts.storage ?? getDefaultStorageAdapter(); this.logger = opts.logger; - - this.initialized = this.loadFromStorage().then(() => { - if (this.queue.length > 0) { - this.schedule(this.flushDelayMs); - } - }); } async enqueue(event: BulkEvent) { - await this.initialized; this.queue.push(event); if (this.queue.length > this.maxSize) { @@ -181,8 +120,6 @@ export class BulkQueue { } } - await this.saveToStorage(); - if (this.queue.length >= this.maxSize) { void this.flush(); return; @@ -192,8 +129,6 @@ export class BulkQueue { } async flush() { - await this.initialized; - if (this.inFlight || this.queue.length === 0) { return; } @@ -224,7 +159,6 @@ export class BulkQueue { this.firstFailureAt = null; this.consecutiveFailures = 0; this.lastWarnAt = null; - await this.saveToStorage(); nextDelayMs = this.flushDelayMs; } catch (error) { const now = Date.now(); @@ -273,7 +207,6 @@ export class BulkQueue { } async size() { - await this.initialized; return this.queue.length; } @@ -301,36 +234,4 @@ export class BulkQueue { void this.flush(); }, delayMs); } - - private async saveToStorage() { - try { - if (this.queue.length === 0 && this.storage.removeItem) { - await this.storage.removeItem(this.storageKey); - return; - } - await this.storage.setItem(this.storageKey, JSON.stringify(this.queue)); - } catch (error) { - this.logger?.warn("failed to persist bulk queue", error); - } - } - - private async loadFromStorage() { - try { - const raw = await this.storage.getItem(this.storageKey); - if (!raw) { - return; - } - - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - throw new Error("invalid stored bulk queue"); - } - - this.queue = parsed.filter(isBulkEvent).slice(-this.maxSize); - } catch (error) { - this.logger?.warn("failed to restore bulk queue from storage", error); - this.queue = []; - await this.saveToStorage(); - } - } } diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index aacfb28f..06f8aa19 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -309,6 +309,7 @@ export type InitOptions = ReflagDeprecatedContext & { /** * Queue settings for tracking updates sent to `/bulk`. * Applies to user/company updates, check events, and prompt events. + * This queue is in-memory only and does not persist across page reloads. */ trackingQueue?: { /** @@ -479,8 +480,6 @@ export class ReflagClient { maxSize: opts.trackingQueue?.maxSize, retryBaseDelayMs: opts.trackingQueue?.retryBaseDelayMs, retryMaxDelayMs: opts.trackingQueue?.retryMaxDelayMs, - storage: opts.storage, - storageKey: `__reflag_bulk_queue_v1:${this.config.apiBaseUrl}:${this.publishableKey}`, logger: this.logger, }, ); diff --git a/packages/browser-sdk/test/bulkQueue.test.ts b/packages/browser-sdk/test/bulkQueue.test.ts index 3086a4c9..759b3f16 100644 --- a/packages/browser-sdk/test/bulkQueue.test.ts +++ b/packages/browser-sdk/test/bulkQueue.test.ts @@ -1,21 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BulkEvent, BulkQueue } from "../src/bulkQueue"; -import { StorageAdapter } from "../src/storage"; - -function createMemoryStorage(): StorageAdapter { - const store = new Map(); - - return { - getItem: async (key) => store.get(key) ?? null, - setItem: async (key, value) => { - store.set(key, value); - }, - removeItem: async (key) => { - store.delete(key); - }, - }; -} const userEvent: BulkEvent = { type: "user", @@ -54,7 +39,6 @@ describe("BulkQueue", () => { .mockResolvedValue(new Response("", { status: 200 })); const queue = new BulkQueue(sendBulk, { flushDelayMs: 75, - storage: createMemoryStorage(), }); await queue.enqueue(userEvent); @@ -79,7 +63,6 @@ describe("BulkQueue", () => { flushDelayMs: 10, retryBaseDelayMs: 20, retryMaxDelayMs: 20, - storage: createMemoryStorage(), }); await queue.enqueue(trackEvent); @@ -108,7 +91,6 @@ describe("BulkQueue", () => { const queue = new BulkQueue(sendBulk, { flushDelayMs: 10_000, maxSize: 2, - storage: createMemoryStorage(), }); await queue.enqueue(userEvent); @@ -122,14 +104,12 @@ describe("BulkQueue", () => { resolveSend?.(new Response("", { status: 200 })); }); - it("restores queued events from storage", async () => { - const storage = createMemoryStorage(); + it("does not share queue state between instances", async () => { const firstSend = vi .fn<(events: BulkEvent[]) => Promise>() .mockResolvedValue(new Response("", { status: 200 })); const firstQueue = new BulkQueue(firstSend, { flushDelayMs: 10_000, - storage, }); await firstQueue.enqueue(userEvent); @@ -140,12 +120,10 @@ describe("BulkQueue", () => { .mockResolvedValue(new Response("", { status: 200 })); const secondQueue = new BulkQueue(secondSend, { flushDelayMs: 10_000, - storage, }); - expect(await secondQueue.size()).toBe(1); - + expect(await secondQueue.size()).toBe(0); await secondQueue.flush(); - expect(secondSend).toHaveBeenCalledWith([userEvent]); + expect(secondSend).not.toHaveBeenCalled(); }); }); From d6d6453ab032c4fe0c5d51930c35d7cf3e179676 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 5 Mar 2026 20:18:16 +0100 Subject: [PATCH 3/7] fix(browser-sdk): drop non-retriable 4xx bulk batches Treat 4xx /bulk responses as non-retriable, log error, and continue without retrying the failed batch. --- packages/browser-sdk/src/bulkQueue.ts | 30 ++++++++++++++++++ packages/browser-sdk/test/bulkQueue.test.ts | 34 +++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/browser-sdk/src/bulkQueue.ts b/packages/browser-sdk/src/bulkQueue.ts index 540af3c3..786e3687 100644 --- a/packages/browser-sdk/src/bulkQueue.ts +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -145,6 +145,24 @@ export class BulkQueue { try { const res = await this.sendBulk(batch); if (!res.ok) { + if (res.status >= 400 && res.status < 500) { + const responseBody = await this.getResponseBodyPreview(res); + this.queue.splice(0, batch.length); + this.retryCount = 0; + this.firstFailureAt = null; + this.consecutiveFailures = 0; + this.lastWarnAt = null; + this.logger?.error( + "bulk request failed with non-retriable status; dropping batch", + { + status: res.status, + statusText: res.statusText, + responseBody, + }, + ); + nextDelayMs = this.flushDelayMs; + return; + } throw new Error(`unexpected status ${res.status}`); } @@ -234,4 +252,16 @@ export class BulkQueue { void this.flush(); }, delayMs); } + + private async getResponseBodyPreview(res: Response) { + try { + const body = await res.text(); + if (!body) { + return undefined; + } + return body.slice(0, 500); + } catch { + return undefined; + } + } } diff --git a/packages/browser-sdk/test/bulkQueue.test.ts b/packages/browser-sdk/test/bulkQueue.test.ts index 759b3f16..3c48ef63 100644 --- a/packages/browser-sdk/test/bulkQueue.test.ts +++ b/packages/browser-sdk/test/bulkQueue.test.ts @@ -78,6 +78,40 @@ describe("BulkQueue", () => { expect(sendBulk).toHaveBeenNthCalledWith(2, [trackEvent]); }); + it("drops 4xx responses, logs error, and does not retry", async () => { + const sendBulk = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockResolvedValue(new Response("invalid payload", { status: 400 })); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const queue = new BulkQueue(sendBulk, { + flushDelayMs: 10, + retryBaseDelayMs: 20, + retryMaxDelayMs: 20, + logger, + }); + + await queue.enqueue(trackEvent); + + await vi.advanceTimersByTimeAsync(10); + expect(sendBulk).toHaveBeenCalledTimes(1); + expect(await queue.size()).toBe(0); + expect(logger.error).toHaveBeenCalledWith( + "bulk request failed with non-retriable status; dropping batch", + expect.objectContaining({ + status: 400, + responseBody: "invalid payload", + }), + ); + + await vi.advanceTimersByTimeAsync(100); + expect(sendBulk).toHaveBeenCalledTimes(1); + }); + it("keeps only the newest events when max size is exceeded", async () => { let resolveSend: ((value: Response) => void) | undefined; const sendBulk = vi From 9b8f90c7d32239daf146b0f134ceba971d258b3d Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 6 Mar 2026 10:09:23 +0100 Subject: [PATCH 4/7] refactor(browser-sdk): simplify bulk queue flush model Remove drain API, keep single in-flight + pending queue model, and make stop perform a final two-flush attempt then fail if events remain. --- packages/browser-sdk/src/bulkQueue.ts | 177 +++++++++++--------- packages/browser-sdk/src/client.ts | 14 +- packages/browser-sdk/test/bulkQueue.test.ts | 86 ++++++++++ packages/browser-sdk/test/client.test.ts | 14 ++ 4 files changed, 213 insertions(+), 78 deletions(-) diff --git a/packages/browser-sdk/src/bulkQueue.ts b/packages/browser-sdk/src/bulkQueue.ts index 786e3687..b31f3683 100644 --- a/packages/browser-sdk/src/bulkQueue.ts +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -73,7 +73,8 @@ export class BulkQueue { private queue: BulkEvent[] = []; private timer: ReturnType | null = null; - private inFlight = false; + private inFlightBatch: BulkEvent[] | null = null; + private inFlightPromise: Promise | null = null; private retryCount = 0; private consecutiveFailures = 0; private firstFailureAt: number | null = null; @@ -97,30 +98,10 @@ export class BulkQueue { async enqueue(event: BulkEvent) { this.queue.push(event); + this.trimPendingQueueToCapacity(); - if (this.queue.length > this.maxSize) { - const removed = this.queue.length - this.maxSize; - this.queue = this.queue.slice(-this.maxSize); - this.totalDroppedEvents += removed; - this.droppedSinceLastError += removed; - - const now = Date.now(); - if ( - !this.lastDropErrorAt || - now - this.lastDropErrorAt >= DROP_ERROR_THROTTLE_MS - ) { - this.logger?.error("bulk queue dropped events due to max size", { - droppedEvents: this.droppedSinceLastError, - totalDroppedEvents: this.totalDroppedEvents, - queueSize: this.queue.length, - maxSize: this.maxSize, - }); - this.lastDropErrorAt = now; - this.droppedSinceLastError = 0; - } - } - - if (this.queue.length >= this.maxSize) { + const maxPending = Math.max(0, this.maxSize - this.getInFlightBatchSize()); + if (this.queue.length > 0 && this.queue.length >= maxPending) { void this.flush(); return; } @@ -129,7 +110,12 @@ export class BulkQueue { } async flush() { - if (this.inFlight || this.queue.length === 0) { + if (this.inFlightPromise) { + await this.inFlightPromise; + return; + } + + if (this.queue.length === 0) { return; } @@ -138,8 +124,56 @@ export class BulkQueue { this.timer = null; } - this.inFlight = true; - const batch = this.queue.slice(0, this.maxSize); + const batch = this.queue.splice(0, this.maxSize); + this.inFlightBatch = batch; + + const sendPromise = this.sendBatch(batch); + this.inFlightPromise = sendPromise; + let nextDelayMs: number | null = null; + try { + nextDelayMs = await sendPromise; + } finally { + if (this.inFlightPromise === sendPromise) { + this.inFlightPromise = null; + } + this.inFlightBatch = null; + } + + if (this.queue.length > 0 && !this.timer && nextDelayMs !== null) { + this.schedule(nextDelayMs); + } + } + + async size() { + return this.queue.length + this.getInFlightBatchSize(); + } + + private getRetryDelay() { + const maxExponent = 6; + const exponent = Math.min(this.retryCount - 1, maxExponent); + return Math.min( + this.retryBaseDelayMs * 2 ** exponent, + this.retryMaxDelayMs, + ); + } + + private schedule(delayMs: number) { + if (this.timer || this.inFlightPromise || this.queue.length === 0) { + return; + } + + if (delayMs <= 0) { + void this.flush(); + return; + } + + this.timer = setTimeout(() => { + this.timer = null; + void this.flush(); + }, delayMs); + } + + private async sendBatch(batch: BulkEvent[]) { let nextDelayMs: number | null = null; try { @@ -147,7 +181,6 @@ export class BulkQueue { if (!res.ok) { if (res.status >= 400 && res.status < 500) { const responseBody = await this.getResponseBodyPreview(res); - this.queue.splice(0, batch.length); this.retryCount = 0; this.firstFailureAt = null; this.consecutiveFailures = 0; @@ -161,24 +194,25 @@ export class BulkQueue { }, ); nextDelayMs = this.flushDelayMs; - return; + } else { + throw new Error(`unexpected status ${res.status}`); } - throw new Error(`unexpected status ${res.status}`); - } - - this.queue.splice(0, batch.length); - this.retryCount = 0; - if (this.firstFailureAt !== null && this.consecutiveFailures > 0) { - this.logger?.info("bulk delivery recovered", { - outageMs: Date.now() - this.firstFailureAt, - failedAttempts: this.consecutiveFailures, - }); + } else { + this.retryCount = 0; + if (this.firstFailureAt !== null && this.consecutiveFailures > 0) { + this.logger?.info("bulk delivery recovered", { + outageMs: Date.now() - this.firstFailureAt, + failedAttempts: this.consecutiveFailures, + }); + } + this.firstFailureAt = null; + this.consecutiveFailures = 0; + this.lastWarnAt = null; + nextDelayMs = this.flushDelayMs; } - this.firstFailureAt = null; - this.consecutiveFailures = 0; - this.lastWarnAt = null; - nextDelayMs = this.flushDelayMs; } catch (error) { + this.queue = batch.concat(this.queue); + const now = Date.now(); if (this.firstFailureAt === null) { this.firstFailureAt = now; @@ -189,7 +223,7 @@ export class BulkQueue { nextDelayMs = retryInMs; this.logger?.info("bulk retry scheduled", { retryInMs, - queueSize: this.queue.length, + queueSize: this.queue.length + this.getInFlightBatchSize(), consecutiveFailures: this.consecutiveFailures, }); @@ -203,54 +237,43 @@ export class BulkQueue { this.logger?.warn("bulk delivery degraded", { consecutiveFailures: this.consecutiveFailures, outageMs, - queueSize: this.queue.length, + queueSize: this.queue.length + this.getInFlightBatchSize(), retryInMs, error, }); this.lastWarnAt = now; } - this.schedule(retryInMs); - } finally { - this.inFlight = false; } - if ( - this.queue.length > 0 && - !this.timer && - !this.inFlight && - nextDelayMs !== null - ) { - this.schedule(nextDelayMs); - } - } - - async size() { - return this.queue.length; + return nextDelayMs; } - private getRetryDelay() { - const maxExponent = 6; - const exponent = Math.min(this.retryCount - 1, maxExponent); - return Math.min( - this.retryBaseDelayMs * 2 ** exponent, - this.retryMaxDelayMs, - ); + private getInFlightBatchSize() { + return this.inFlightBatch?.length ?? 0; } - private schedule(delayMs: number) { - if (this.timer || this.inFlight || this.queue.length === 0) { + private trimPendingQueueToCapacity() { + const maxPending = Math.max(0, this.maxSize - this.getInFlightBatchSize()); + if (this.queue.length <= maxPending) { return; } - if (delayMs <= 0) { - void this.flush(); - return; - } + const removed = this.queue.length - maxPending; + this.queue = maxPending === 0 ? [] : this.queue.slice(-maxPending); + this.totalDroppedEvents += removed; + this.droppedSinceLastError += removed; - this.timer = setTimeout(() => { - this.timer = null; - void this.flush(); - }, delayMs); + const now = Date.now(); + if (!this.lastDropErrorAt || now - this.lastDropErrorAt >= DROP_ERROR_THROTTLE_MS) { + this.logger?.error("bulk queue dropped events due to max size", { + droppedEvents: this.droppedSinceLastError, + totalDroppedEvents: this.totalDroppedEvents, + queueSize: this.queue.length + this.getInFlightBatchSize(), + maxSize: this.maxSize, + }); + this.lastDropErrorAt = now; + this.droppedSinceLastError = 0; + } } private async getResponseBodyPreview(res: Response) { diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 06f8aa19..f7cc2a52 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -594,7 +594,19 @@ export class ReflagClient { * **/ async stop() { - await this.bulkQueue?.flush(); + if (this.bulkQueue) { + await this.bulkQueue.flush(); + let remaining = await this.bulkQueue.size(); + if (remaining > 0) { + await this.bulkQueue.flush(); + remaining = await this.bulkQueue.size(); + } + if (remaining > 0) { + throw new Error( + `failed to flush all queued bulk events during stop (${remaining} remaining)`, + ); + } + } if (this.autoFeedback) { // ensure fully initialized before stopping diff --git a/packages/browser-sdk/test/bulkQueue.test.ts b/packages/browser-sdk/test/bulkQueue.test.ts index 3c48ef63..984803d1 100644 --- a/packages/browser-sdk/test/bulkQueue.test.ts +++ b/packages/browser-sdk/test/bulkQueue.test.ts @@ -23,6 +23,14 @@ const trackEvent: BulkEvent = { attributes: { source: "banner" }, }; +const lateTrackEvent: BulkEvent = { + type: "event", + userId: "u1", + companyId: "c1", + event: "late-clicked", + attributes: { source: "footer" }, +}; + describe("BulkQueue", () => { beforeEach(() => { vi.useFakeTimers(); @@ -112,6 +120,40 @@ describe("BulkQueue", () => { expect(sendBulk).toHaveBeenCalledTimes(1); }); + it("does not drop newly queued events when an older batch completes", async () => { + let resolveFirstSend: ((res: Response) => void) | undefined; + const firstSend = new Promise((resolve) => { + resolveFirstSend = resolve; + }); + const sendBulk = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockReturnValueOnce(firstSend) + .mockResolvedValue(new Response("", { status: 200 })); + + const queue = new BulkQueue(sendBulk, { + flushDelayMs: 1, + maxSize: 3, + }); + + await queue.enqueue(userEvent); + await queue.enqueue(companyEvent); + await vi.advanceTimersByTimeAsync(1); + expect(sendBulk).toHaveBeenCalledTimes(1); + expect(sendBulk).toHaveBeenNthCalledWith(1, [userEvent, companyEvent]); + + await queue.enqueue(trackEvent); + await queue.enqueue(lateTrackEvent); + + expect(await queue.size()).toBe(3); + + resolveFirstSend?.(new Response("", { status: 200 })); + await vi.advanceTimersByTimeAsync(1); + + expect(sendBulk).toHaveBeenCalledTimes(2); + expect(sendBulk).toHaveBeenNthCalledWith(2, [lateTrackEvent]); + expect(await queue.size()).toBe(0); + }); + it("keeps only the newest events when max size is exceeded", async () => { let resolveSend: ((value: Response) => void) | undefined; const sendBulk = vi @@ -160,4 +202,48 @@ describe("BulkQueue", () => { await secondQueue.flush(); expect(secondSend).not.toHaveBeenCalled(); }); + + it("requires a second flush to send pending events after an in-flight batch", async () => { + let resolveFirstSend: ((res: Response) => void) | undefined; + const firstSend = new Promise((resolve) => { + resolveFirstSend = resolve; + }); + const sendBulk = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockReturnValueOnce(firstSend) + .mockResolvedValue(new Response("", { status: 200 })); + + const queue = new BulkQueue(sendBulk, { + flushDelayMs: 10_000, + maxSize: 4, + }); + + await queue.enqueue(userEvent); + await queue.enqueue(companyEvent); + void queue.flush(); + expect(sendBulk).toHaveBeenNthCalledWith(1, [userEvent, companyEvent]); + + await queue.enqueue(trackEvent); + await queue.enqueue(lateTrackEvent); + + let waitedForInFlight = false; + const flushWhileInFlight = queue.flush().then(() => { + waitedForInFlight = true; + }); + + await Promise.resolve(); + expect(waitedForInFlight).toBe(false); + + resolveFirstSend?.(new Response("", { status: 200 })); + await flushWhileInFlight; + + expect(waitedForInFlight).toBe(true); + expect(sendBulk).toHaveBeenCalledTimes(1); + expect(await queue.size()).toBe(2); + + await queue.flush(); + expect(sendBulk).toHaveBeenCalledTimes(2); + expect(sendBulk).toHaveBeenNthCalledWith(2, [trackEvent, lateTrackEvent]); + expect(await queue.size()).toBe(0); + }); }); diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index b227b0a1..1799ddfd 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -194,6 +194,20 @@ describe("ReflagClient", () => { }); }); + describe("stop", () => { + it("throws if queued bulk events remain after final flush attempt", async () => { + const bulkQueue = client["bulkQueue"]; + expect(bulkQueue).toBeDefined(); + + vi.spyOn(bulkQueue!, "flush").mockResolvedValueOnce().mockResolvedValueOnce(); + vi.spyOn(bulkQueue!, "size").mockResolvedValueOnce(1).mockResolvedValueOnce(1); + + await expect(client.stop()).rejects.toThrow( + "failed to flush all queued bulk events during stop (1 remaining)", + ); + }); + }); + describe("offline mode", () => { it("should not make HTTP calls when offline", async () => { client = new ReflagClient({ From 3c0e5f7a35716c50219baea3fc6d0c6efe75b64b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 6 Mar 2026 12:26:08 +0100 Subject: [PATCH 5/7] browser-sdk: persist bulk queue in sessionStorage and increase flush delay --- packages/browser-sdk/src/bulkQueue.ts | 121 ++++++++++++++++++++ packages/browser-sdk/src/client.ts | 4 +- packages/browser-sdk/src/config.ts | 2 +- packages/browser-sdk/test/bulkQueue.test.ts | 8 +- packages/browser-sdk/test/client.test.ts | 2 + packages/browser-sdk/test/init.test.ts | 2 + packages/browser-sdk/test/usage.test.ts | 4 + 7 files changed, 138 insertions(+), 5 deletions(-) diff --git a/packages/browser-sdk/src/bulkQueue.ts b/packages/browser-sdk/src/bulkQueue.ts index b31f3683..7da40dc8 100644 --- a/packages/browser-sdk/src/bulkQueue.ts +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -5,6 +5,8 @@ import { BULK_QUEUE_RETRY_MAX_DELAY_MS, } from "./config"; import { Logger } from "./logger"; + +const BULK_QUEUE_STORAGE_KEY = "__reflag_bulk_queue_v1"; const WARN_AFTER_CONSECUTIVE_FAILURES = 10; const WARN_AFTER_FAILURE_MS = 5 * 60 * 1000; const WARN_THROTTLE_MS = 15 * 60 * 1000; @@ -60,14 +62,71 @@ export type BulkQueueOptions = { maxSize?: number; retryBaseDelayMs?: number; retryMaxDelayMs?: number; + storageKey?: string; logger?: Logger; }; +function getSessionStorage(): Storage | null { + try { + if (typeof sessionStorage === "undefined") { + return null; + } + return sessionStorage; + } catch { + return null; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isBulkEvent(value: unknown): value is BulkEvent { + if (!isObject(value) || typeof value.type !== "string") { + return false; + } + + if (value.type === "user") { + return typeof value.userId === "string"; + } + + if (value.type === "company") { + return typeof value.companyId === "string"; + } + + if (value.type === "event") { + return typeof value.userId === "string" && typeof value.event === "string"; + } + + if (value.type === "feature-flag-event") { + return ( + typeof value.key === "string" && + (value.action === "check-is-enabled" || value.action === "check-config") + ); + } + + if (value.type === "prompt-event") { + return ( + typeof value.featureId === "string" && + typeof value.promptId === "string" && + typeof value.userId === "string" && + typeof value.promptedQuestion === "string" && + (value.action === "received" || + value.action === "shown" || + value.action === "dismissed") + ); + } + + return false; +} + export class BulkQueue { private readonly flushDelayMs: number; private readonly maxSize: number; private readonly retryBaseDelayMs: number; private readonly retryMaxDelayMs: number; + private readonly storageKey: string; + private readonly storage: Storage | null; private readonly logger?: Logger; private readonly sendBulk: (events: BulkEvent[]) => Promise; @@ -93,12 +152,20 @@ export class BulkQueue { this.retryBaseDelayMs = opts.retryBaseDelayMs ?? BULK_QUEUE_RETRY_BASE_DELAY_MS; this.retryMaxDelayMs = opts.retryMaxDelayMs ?? BULK_QUEUE_RETRY_MAX_DELAY_MS; + this.storageKey = opts.storageKey ?? BULK_QUEUE_STORAGE_KEY; + this.storage = getSessionStorage(); this.logger = opts.logger; + + this.restoreQueueFromStorage(); + if (this.queue.length > 0) { + this.schedule(this.flushDelayMs); + } } async enqueue(event: BulkEvent) { this.queue.push(event); this.trimPendingQueueToCapacity(); + this.persistQueueToStorage(); const maxPending = Math.max(0, this.maxSize - this.getInFlightBatchSize()); if (this.queue.length > 0 && this.queue.length >= maxPending) { @@ -137,6 +204,7 @@ export class BulkQueue { this.inFlightPromise = null; } this.inFlightBatch = null; + this.persistQueueToStorage(); } if (this.queue.length > 0 && !this.timer && nextDelayMs !== null) { @@ -248,6 +316,59 @@ export class BulkQueue { return nextDelayMs; } + private getPersistedQueue() { + const inFlight = this.inFlightBatch ?? []; + return inFlight.concat(this.queue).slice(-this.maxSize); + } + + private persistQueueToStorage() { + if (!this.storage) { + return; + } + + try { + const persisted = this.getPersistedQueue(); + if (persisted.length === 0) { + this.storage.removeItem(this.storageKey); + return; + } + + this.storage.setItem(this.storageKey, JSON.stringify(persisted)); + } catch { + // ignore persistence failures + } + } + + private restoreQueueFromStorage() { + if (!this.storage) { + return; + } + + try { + const raw = this.storage.getItem(this.storageKey); + if (!raw) { + return; + } + + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error("invalid stored bulk queue"); + } + + this.queue = parsed.filter(isBulkEvent).slice(-this.maxSize); + if (this.queue.length === 0) { + this.storage.removeItem(this.storageKey); + } + } catch { + this.queue = []; + try { + this.storage.removeItem(this.storageKey); + } catch { + // ignore cleanup failures + } + } + } + private getInFlightBatchSize() { return this.inFlightBatch?.length ?? 0; } diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index f7cc2a52..fe52b65d 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -309,7 +309,8 @@ export type InitOptions = ReflagDeprecatedContext & { /** * Queue settings for tracking updates sent to `/bulk`. * Applies to user/company updates, check events, and prompt events. - * This queue is in-memory only and does not persist across page reloads. + * Queue data is persisted in `sessionStorage` and restored on reloads + * within the same browser tab. */ trackingQueue?: { /** @@ -480,6 +481,7 @@ export class ReflagClient { maxSize: opts.trackingQueue?.maxSize, retryBaseDelayMs: opts.trackingQueue?.retryBaseDelayMs, retryMaxDelayMs: opts.trackingQueue?.retryMaxDelayMs, + storageKey: `__reflag_bulk_queue_v1:${this.config.apiBaseUrl}:${this.publishableKey}`, logger: this.logger, }, ); diff --git a/packages/browser-sdk/src/config.ts b/packages/browser-sdk/src/config.ts index 3f2a8737..b45ef204 100644 --- a/packages/browser-sdk/src/config.ts +++ b/packages/browser-sdk/src/config.ts @@ -10,7 +10,7 @@ export const SDK_VERSION = `browser-sdk/${version}`; export const FLAG_EVENTS_PER_MIN = 1; export const FLAGS_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000; // expire entirely after 30 days export const BULK_QUEUE_MAX_SIZE = 100; -export const BULK_QUEUE_FLUSH_DELAY_MS = 200; +export const BULK_QUEUE_FLUSH_DELAY_MS = 2000; export const BULK_QUEUE_RETRY_BASE_DELAY_MS = 5000; export const BULK_QUEUE_RETRY_MAX_DELAY_MS = 60_000; diff --git a/packages/browser-sdk/test/bulkQueue.test.ts b/packages/browser-sdk/test/bulkQueue.test.ts index 984803d1..a3613c45 100644 --- a/packages/browser-sdk/test/bulkQueue.test.ts +++ b/packages/browser-sdk/test/bulkQueue.test.ts @@ -34,11 +34,13 @@ const lateTrackEvent: BulkEvent = { describe("BulkQueue", () => { beforeEach(() => { vi.useFakeTimers(); + sessionStorage.clear(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + sessionStorage.clear(); }); it("batches events and flushes after the delay", async () => { @@ -180,7 +182,7 @@ describe("BulkQueue", () => { resolveSend?.(new Response("", { status: 200 })); }); - it("does not share queue state between instances", async () => { + it("restores queue state between instances in the same tab", async () => { const firstSend = vi .fn<(events: BulkEvent[]) => Promise>() .mockResolvedValue(new Response("", { status: 200 })); @@ -198,9 +200,9 @@ describe("BulkQueue", () => { flushDelayMs: 10_000, }); - expect(await secondQueue.size()).toBe(0); + expect(await secondQueue.size()).toBe(1); await secondQueue.flush(); - expect(secondSend).not.toHaveBeenCalled(); + expect(secondSend).toHaveBeenCalledWith([userEvent]); }); it("requires a second flush to send pending events after an in-flight batch", async () => { diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 1799ddfd..73b29e45 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -15,6 +15,7 @@ describe("ReflagClient", () => { beforeEach(() => { localStorage.clear(); + sessionStorage.clear(); client = new ReflagClient({ publishableKey: "test-key", user: { id: "user1" }, @@ -30,6 +31,7 @@ describe("ReflagClient", () => { afterEach(async () => { await client.stop(); localStorage.clear(); + sessionStorage.clear(); vi.unstubAllGlobals(); }); diff --git a/packages/browser-sdk/test/init.test.ts b/packages/browser-sdk/test/init.test.ts index aae137d2..e7ebc68b 100644 --- a/packages/browser-sdk/test/init.test.ts +++ b/packages/browser-sdk/test/init.test.ts @@ -18,11 +18,13 @@ const logger = { beforeEach(() => { localStorage.clear(); + sessionStorage.clear(); vi.clearAllMocks(); }); afterEach(() => { localStorage.clear(); + sessionStorage.clear(); }); describe("init", () => { diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index 4fbbf380..274f311b 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -50,6 +50,7 @@ afterEach(() => { beforeEach(() => { localStorage.clear(); + sessionStorage.clear(); }); describe("usage", () => { @@ -255,6 +256,9 @@ describe("feedback state management", () => { reflagInstance = new ReflagClient({ publishableKey: KEY, user: { id: "foo" }, + trackingQueue: { + flushDelayMs: 0, + }, feedback: { autoFeedbackHandler: callback, }, From 158a006fe4af2d387d3ecf043c689fd4cb627b4c Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 6 Mar 2026 12:58:28 +0100 Subject: [PATCH 6/7] browser-sdk: include API error details in non-retriable bulk logs --- packages/browser-sdk/src/bulkQueue.ts | 73 +++++++++++++++++++-- packages/browser-sdk/test/bulkQueue.test.ts | 42 ++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/packages/browser-sdk/src/bulkQueue.ts b/packages/browser-sdk/src/bulkQueue.ts index 7da40dc8..d3a071fa 100644 --- a/packages/browser-sdk/src/bulkQueue.ts +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -11,6 +11,7 @@ const WARN_AFTER_CONSECUTIVE_FAILURES = 10; const WARN_AFTER_FAILURE_MS = 5 * 60 * 1000; const WARN_THROTTLE_MS = 15 * 60 * 1000; const DROP_ERROR_THROTTLE_MS = 15 * 60 * 1000; +const MAX_RESPONSE_BODY_PREVIEW_CHARS = 500; type PayloadContext = { active?: boolean; @@ -66,6 +67,12 @@ export type BulkQueueOptions = { logger?: Logger; }; +type BulkErrorDetails = { + responseBody?: string; + apiErrorCode?: string; + apiErrorMessage?: string; +}; + function getSessionStorage(): Storage | null { try { if (typeof sessionStorage === "undefined") { @@ -248,17 +255,20 @@ export class BulkQueue { const res = await this.sendBulk(batch); if (!res.ok) { if (res.status >= 400 && res.status < 500) { - const responseBody = await this.getResponseBodyPreview(res); + const errorDetails = await this.getResponseErrorDetails(res); + const errorSummary = this.getApiErrorSummary(errorDetails); this.retryCount = 0; this.firstFailureAt = null; this.consecutiveFailures = 0; this.lastWarnAt = null; this.logger?.error( - "bulk request failed with non-retriable status; dropping batch", + errorSummary + ? `bulk request failed with non-retriable status; dropping batch: ${errorSummary}` + : "bulk request failed with non-retriable status; dropping batch", { status: res.status, statusText: res.statusText, - responseBody, + ...errorDetails, }, ); nextDelayMs = this.flushDelayMs; @@ -397,15 +407,64 @@ export class BulkQueue { } } - private async getResponseBodyPreview(res: Response) { + private getApiErrorSummary(errorDetails: BulkErrorDetails) { + const code = errorDetails.apiErrorCode; + const message = errorDetails.apiErrorMessage; + if (code && message) { + return `${code}: ${message}`; + } + return message ?? code; + } + + private async getResponseErrorDetails(res: Response): Promise { try { const body = await res.text(); if (!body) { - return undefined; + return {}; } - return body.slice(0, 500); + + let apiErrorCode: string | undefined; + let apiErrorMessage: string | undefined; + try { + const parsed: unknown = JSON.parse(body); + const parsedError = this.extractApiError(parsed); + apiErrorCode = parsedError.code; + apiErrorMessage = parsedError.message; + } catch { + // ignore JSON parse failures + } + + return { + responseBody: body.slice(0, MAX_RESPONSE_BODY_PREVIEW_CHARS), + apiErrorCode, + apiErrorMessage, + }; } catch { - return undefined; + return {}; } } + + private extractApiError(value: unknown): { code?: string; message?: string } { + if (!isObject(value)) { + return {}; + } + + const topLevelCode = typeof value.code === "string" ? value.code : undefined; + const topLevelMessage = + typeof value.message === "string" ? value.message : undefined; + + const error = value.error; + if (!isObject(error)) { + return { + code: topLevelCode, + message: topLevelMessage, + }; + } + + return { + code: typeof error.code === "string" ? error.code : topLevelCode, + message: + typeof error.message === "string" ? error.message : topLevelMessage, + }; + } } diff --git a/packages/browser-sdk/test/bulkQueue.test.ts b/packages/browser-sdk/test/bulkQueue.test.ts index a3613c45..d9d8a4f9 100644 --- a/packages/browser-sdk/test/bulkQueue.test.ts +++ b/packages/browser-sdk/test/bulkQueue.test.ts @@ -122,6 +122,48 @@ describe("BulkQueue", () => { expect(sendBulk).toHaveBeenCalledTimes(1); }); + it("includes parsed API error details for non-retriable 4xx responses", async () => { + const body = JSON.stringify({ + success: false, + error: { + message: + 'Invalid publishableKey "pub_prod_vxuMahSZOnhzvAfiOnZ9rj"', + code: "INVALID_API_KEY", + }, + }); + const sendBulk = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockResolvedValue( + new Response(body, { + status: 401, + headers: { "content-type": "application/json" }, + }), + ); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const queue = new BulkQueue(sendBulk, { + flushDelayMs: 10, + logger, + }); + + await queue.enqueue(trackEvent); + await vi.advanceTimersByTimeAsync(10); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("INVALID_API_KEY"), + expect.objectContaining({ + status: 401, + apiErrorCode: "INVALID_API_KEY", + apiErrorMessage: + 'Invalid publishableKey "pub_prod_vxuMahSZOnhzvAfiOnZ9rj"', + }), + ); + }); + it("does not drop newly queued events when an older batch completes", async () => { let resolveFirstSend: ((res: Response) => void) | undefined; const firstSend = new Promise((resolve) => { From 36e4c571b4a0c49cd2440225cde1040b9b4a7244 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 6 Mar 2026 13:52:04 +0100 Subject: [PATCH 7/7] browser-sdk: DRY response error parsing and logging --- packages/browser-sdk/src/bulkQueue.ts | 89 ++------------ packages/browser-sdk/src/client.ts | 9 ++ packages/browser-sdk/src/feedback/feedback.ts | 38 +++++- packages/browser-sdk/src/flag/flags.ts | 33 +++-- packages/browser-sdk/src/sse.ts | 13 +- .../browser-sdk/src/utils/responseError.ts | 114 ++++++++++++++++++ 6 files changed, 199 insertions(+), 97 deletions(-) create mode 100644 packages/browser-sdk/src/utils/responseError.ts diff --git a/packages/browser-sdk/src/bulkQueue.ts b/packages/browser-sdk/src/bulkQueue.ts index d3a071fa..34e928e4 100644 --- a/packages/browser-sdk/src/bulkQueue.ts +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -5,13 +5,13 @@ import { BULK_QUEUE_RETRY_MAX_DELAY_MS, } from "./config"; import { Logger } from "./logger"; +import { logResponseError } from "./utils/responseError"; const BULK_QUEUE_STORAGE_KEY = "__reflag_bulk_queue_v1"; const WARN_AFTER_CONSECUTIVE_FAILURES = 10; const WARN_AFTER_FAILURE_MS = 5 * 60 * 1000; const WARN_THROTTLE_MS = 15 * 60 * 1000; const DROP_ERROR_THROTTLE_MS = 15 * 60 * 1000; -const MAX_RESPONSE_BODY_PREVIEW_CHARS = 500; type PayloadContext = { active?: boolean; @@ -67,12 +67,6 @@ export type BulkQueueOptions = { logger?: Logger; }; -type BulkErrorDetails = { - responseBody?: string; - apiErrorCode?: string; - apiErrorMessage?: string; -}; - function getSessionStorage(): Storage | null { try { if (typeof sessionStorage === "undefined") { @@ -255,22 +249,18 @@ export class BulkQueue { const res = await this.sendBulk(batch); if (!res.ok) { if (res.status >= 400 && res.status < 500) { - const errorDetails = await this.getResponseErrorDetails(res); - const errorSummary = this.getApiErrorSummary(errorDetails); this.retryCount = 0; this.firstFailureAt = null; this.consecutiveFailures = 0; this.lastWarnAt = null; - this.logger?.error( - errorSummary - ? `bulk request failed with non-retriable status; dropping batch: ${errorSummary}` - : "bulk request failed with non-retriable status; dropping batch", - { - status: res.status, - statusText: res.statusText, - ...errorDetails, - }, - ); + if (this.logger) { + await logResponseError({ + logger: this.logger, + res, + message: + "bulk request failed with non-retriable status; dropping batch", + }); + } nextDelayMs = this.flushDelayMs; } else { throw new Error(`unexpected status ${res.status}`); @@ -406,65 +396,4 @@ export class BulkQueue { this.droppedSinceLastError = 0; } } - - private getApiErrorSummary(errorDetails: BulkErrorDetails) { - const code = errorDetails.apiErrorCode; - const message = errorDetails.apiErrorMessage; - if (code && message) { - return `${code}: ${message}`; - } - return message ?? code; - } - - private async getResponseErrorDetails(res: Response): Promise { - try { - const body = await res.text(); - if (!body) { - return {}; - } - - let apiErrorCode: string | undefined; - let apiErrorMessage: string | undefined; - try { - const parsed: unknown = JSON.parse(body); - const parsedError = this.extractApiError(parsed); - apiErrorCode = parsedError.code; - apiErrorMessage = parsedError.message; - } catch { - // ignore JSON parse failures - } - - return { - responseBody: body.slice(0, MAX_RESPONSE_BODY_PREVIEW_CHARS), - apiErrorCode, - apiErrorMessage, - }; - } catch { - return {}; - } - } - - private extractApiError(value: unknown): { code?: string; message?: string } { - if (!isObject(value)) { - return {}; - } - - const topLevelCode = typeof value.code === "string" ? value.code : undefined; - const topLevelMessage = - typeof value.message === "string" ? value.message : undefined; - - const error = value.error; - if (!isObject(error)) { - return { - code: topLevelCode, - message: topLevelMessage, - }; - } - - return { - code: typeof error.code === "string" ? error.code : topLevelCode, - message: - typeof error.message === "string" ? error.message : topLevelMessage, - }; - } } diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index fe52b65d..e04f3375 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -29,6 +29,7 @@ import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; import { StorageAdapter } from "./storage"; import { showToolbarToggle } from "./toolbar"; +import { logResponseError } from "./utils/responseError"; const isMobile = typeof window !== "undefined" && window.innerWidth < 768; const isNode = typeof document === "undefined"; // deno supports "window" but not "document" according to https://remix.run/docs/en/main/guides/gotchas @@ -826,6 +827,14 @@ export class ReflagClient { payload.companyId = String(this.context.company?.id); const res = await this.httpClient.post({ path: "/event", body: payload }); + if (!res.ok) { + await logResponseError({ + logger: this.logger, + res, + message: "track request failed", + extra: { event: eventName }, + }); + } this.logger.debug(`sent event`, payload); this.hooks.trigger("track", { diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index 0e38b055..e3b810d8 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -1,6 +1,7 @@ import type { BulkEvent } from "../bulkQueue"; import { HttpClient } from "../httpClient"; import { Logger } from "../logger"; +import { logResponseError } from "../utils/responseError"; import { AblySSEChannel, openAblySSEChannel } from "../sse"; import { Position } from "../ui/types"; @@ -262,6 +263,14 @@ export async function feedback( body: feedbackPayload, }); + if (!res.ok) { + await logResponseError({ + logger, + res, + message: "feedback request failed", + }); + } + logger.debug(`sent feedback`, res); return res; } @@ -460,6 +469,18 @@ export class AutoFeedback { path: `/feedback/prompt-events`, body: payload, }); + if (!res.ok) { + await logResponseError({ + logger: this.logger, + res, + message: "prompt event request failed", + extra: { + action: payload.action, + featureId: payload.featureId, + promptId: payload.promptId, + }, + }); + } this.logger.debug(`sent prompt event`, res); return res; } @@ -482,11 +503,18 @@ export class AutoFeedback { }); this.logger.debug(`automatic feedback status sent`, res); - if (res.ok) { - const body: { success: boolean; channel?: string } = await res.json(); - if (body.success && body.channel) { - return body.channel; - } + if (!res.ok) { + await logResponseError({ + logger: this.logger, + res, + message: "automatic feedback init request failed", + }); + return; + } + + const body: { success: boolean; channel?: string } = await res.json(); + if (body.success && body.channel) { + return body.channel; } } } catch (e) { diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 65fbd807..d38d0241 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -9,6 +9,10 @@ import RateLimiter from "../rateLimiter"; import { getDefaultStorageAdapter, StorageAdapter } from "../storage"; import { createAbortController } from "../utils/abortController"; import { createEventTarget } from "../utils/eventTarget"; +import { + logResponseError, + parseResponseError, +} from "../utils/responseError"; import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagCache"; @@ -383,6 +387,18 @@ export class FlagsClient { path: "features/events", body: payload, }) + .then(async (res) => { + if (res.ok) { + return; + } + + await logResponseError({ + logger: this.logger, + level: "warn", + res, + message: "failed to send flag check event", + }); + }) .catch((e: any) => { this.logger.warn(`failed to send flag check event`, e); }); @@ -405,18 +421,15 @@ export class FlagsClient { }); if (!res.ok) { - let errorBody = null; - try { - errorBody = await res.json(); - } catch { - // ignore - } + const { errorDetails, errorSummary } = await parseResponseError(res); + const fallbackBody = errorDetails.responseBody + ? ` - ${errorDetails.responseBody}` + : ""; throw new Error( - "unexpected response code: " + - res.status + - " - " + - JSON.stringify(errorBody), + `unexpected response code: ${res.status}${ + errorSummary ? ` - ${errorSummary}` : fallbackBody + }`, ); } diff --git a/packages/browser-sdk/src/sse.ts b/packages/browser-sdk/src/sse.ts index 6f2449eb..d668036a 100644 --- a/packages/browser-sdk/src/sse.ts +++ b/packages/browser-sdk/src/sse.ts @@ -5,6 +5,7 @@ import { } from "./feedback/promptStorage"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix } from "./logger"; +import { logResponseError } from "./utils/responseError"; interface AblyTokenDetails { token: string; @@ -57,7 +58,11 @@ export class AblySSEChannel { } } - this.logger.error("server did not release a token request", res); + await logResponseError({ + logger: this.logger, + res, + message: "server did not release a token request", + }); return; } @@ -99,7 +104,11 @@ export class AblySSEChannel { return details.token; } - this.logger.error("server did not release a token"); + await logResponseError({ + logger: this.logger, + res, + message: "server did not release a token", + }); return; } diff --git a/packages/browser-sdk/src/utils/responseError.ts b/packages/browser-sdk/src/utils/responseError.ts new file mode 100644 index 00000000..741f9264 --- /dev/null +++ b/packages/browser-sdk/src/utils/responseError.ts @@ -0,0 +1,114 @@ +export type ResponseErrorDetails = { + responseBody?: string; + apiErrorCode?: string; + apiErrorMessage?: string; +}; + +export type ParsedResponseError = { + errorDetails: ResponseErrorDetails; + errorSummary?: string; +}; + +type LogLevel = "debug" | "info" | "warn" | "error"; + +type ResponseLogger = { + [key in LogLevel]: (message: string, ...args: any[]) => void; +}; + +const MAX_RESPONSE_BODY_PREVIEW_CHARS = 500; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function extractApiError(value: unknown): { code?: string; message?: string } { + if (!isObject(value)) { + return {}; + } + + const topLevelCode = typeof value.code === "string" ? value.code : undefined; + const topLevelMessage = + typeof value.message === "string" ? value.message : undefined; + + const error = value.error; + if (!isObject(error)) { + return { + code: topLevelCode, + message: topLevelMessage, + }; + } + + return { + code: typeof error.code === "string" ? error.code : topLevelCode, + message: typeof error.message === "string" ? error.message : topLevelMessage, + }; +} + +export async function parseResponseErrorDetails( + res: Response, +): Promise { + try { + const body = await res.text(); + if (!body) { + return {}; + } + + let apiErrorCode: string | undefined; + let apiErrorMessage: string | undefined; + try { + const parsed: unknown = JSON.parse(body); + const parsedError = extractApiError(parsed); + apiErrorCode = parsedError.code; + apiErrorMessage = parsedError.message; + } catch { + // ignore JSON parse failures + } + + return { + responseBody: body.slice(0, MAX_RESPONSE_BODY_PREVIEW_CHARS), + apiErrorCode, + apiErrorMessage, + }; + } catch { + return {}; + } +} + +export function formatResponseErrorSummary(details: ResponseErrorDetails) { + if (details.apiErrorCode && details.apiErrorMessage) { + return `${details.apiErrorCode}: ${details.apiErrorMessage}`; + } + + return details.apiErrorMessage ?? details.apiErrorCode; +} + +export async function parseResponseError( + res: Response, +): Promise { + const errorDetails = await parseResponseErrorDetails(res); + const errorSummary = formatResponseErrorSummary(errorDetails); + return { errorDetails, errorSummary }; +} + +export async function logResponseError(args: { + logger: ResponseLogger; + level?: LogLevel; + res: Response; + message: string; + extra?: Record; +}) { + const { logger, level = "error", res, message, extra } = args; + const { errorDetails, errorSummary } = await parseResponseError(res); + + logger[level]( + errorSummary ? `${message}: ${errorSummary}` : message, + { + status: res.status, + statusText: res.statusText, + ...errorDetails, + ...extra, + }, + ); + + return { errorDetails, errorSummary }; +}