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..0d83c611 --- /dev/null +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -0,0 +1,403 @@ +import { logResponseError } from "./utils/responseError"; +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"; + +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; + 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; + + private queue: BulkEvent[] = []; + private timer: ReturnType | null = null; + private inFlightBatch: BulkEvent[] | null = null; + private inFlightPromise: Promise | null = null; + 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; + + 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 = 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) { + void this.flush(); + return; + } + + this.schedule(this.flushDelayMs); + } + + async flush() { + if (this.inFlightPromise) { + await this.inFlightPromise; + return; + } + + if (this.queue.length === 0) { + return; + } + + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + 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; + this.persistQueueToStorage(); + } + + 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 { + const res = await this.sendBulk(batch); + if (!res.ok) { + if (res.status >= 400 && res.status < 500) { + this.retryCount = 0; + this.firstFailureAt = null; + this.consecutiveFailures = 0; + this.lastWarnAt = null; + 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}`); + } + } 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; + } + } catch (error) { + this.queue = batch.concat(this.queue); + + 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 + this.getInFlightBatchSize(), + 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 + this.getInFlightBatchSize(), + retryInMs, + error, + }); + this.lastWarnAt = now; + } + } + + 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; + } + + private trimPendingQueueToCapacity() { + const maxPending = Math.max(0, this.maxSize - this.getInFlightBatchSize()); + if (this.queue.length <= maxPending) { + return; + } + + const removed = this.queue.length - maxPending; + this.queue = maxPending === 0 ? [] : this.queue.slice(-maxPending); + 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 + this.getInFlightBatchSize(), + maxSize: this.maxSize, + }); + this.lastDropErrorAt = now; + this.droppedSinceLastError = 0; + } + } +} diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 84cd6667..461c350e 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -16,6 +16,8 @@ import { RawFlags, } from "./flag/flags"; import { ToolbarPosition } from "./ui/types"; +import { logResponseError } from "./utils/responseError"; +import { BulkEvent, BulkQueue } from "./bulkQueue"; import { API_BASE_URL, APP_BASE_URL, @@ -304,6 +306,40 @@ 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. + * Queue data is persisted in `sessionStorage` and restored on reloads + * within the same browser tab. + */ + 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 +431,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 +474,21 @@ 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, + 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 +503,9 @@ export class ReflagClient { fallbackFlags: opts.fallbackFlags, offline: this.config.offline, storage: opts.storage, + enqueueBulkEvent: bulkQueue + ? (event) => bulkQueue.enqueue(event) + : undefined, }, ); @@ -473,6 +528,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 +597,20 @@ export class ReflagClient { * **/ async stop() { + 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 await this.autoFeedbackInit; @@ -731,7 +801,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 +826,16 @@ 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 }); + 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", { eventName, @@ -1001,17 +1082,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 +1123,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..b45ef204 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 = 2000; +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..53272d76 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -1,7 +1,9 @@ +import type { BulkEvent } from "../bulkQueue"; import { HttpClient } from "../httpClient"; import { Logger } from "../logger"; import { AblySSEChannel, openAblySSEChannel } from "../sse"; import { Position } from "../ui/types"; +import { logResponseError } from "../utils/responseError"; import { FeedbackSubmission, @@ -261,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; } @@ -277,6 +287,7 @@ export class AutoFeedback { private userId: string, private position: Position = DEFAULT_POSITION, private feedbackTranslations: Partial = {}, + private enqueueBulkEvent?: (event: BulkEvent) => Promise, ) {} /** @@ -445,10 +456,31 @@ 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, }); + 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; } @@ -471,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 1a776f6a..0a131c46 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"; @@ -8,6 +9,7 @@ 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"; @@ -189,6 +191,7 @@ type FlagsClientOptions = Partial & { cache?: FlagCache; rateLimiter?: RateLimiter; storage?: StorageAdapter; + enqueueBulkEvent?: (event: BulkEvent) => Promise; }; /** @@ -208,6 +211,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 +228,7 @@ export class FlagsClient { rateLimiter, fallbackFlags, storage, + enqueueBulkEvent, ...config }: FlagsClientOptions = {}, ) { @@ -235,6 +240,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 +365,41 @@ 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, + }) + .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); + }); + } this.logger.debug(`sent flag event`, payload); cb(); @@ -385,18 +418,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..51912601 100644 --- a/packages/browser-sdk/src/sse.ts +++ b/packages/browser-sdk/src/sse.ts @@ -3,6 +3,7 @@ import { getAuthToken, rememberAuthToken, } from "./feedback/promptStorage"; +import { logResponseError } from "./utils/responseError"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix } from "./logger"; @@ -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..43c6a81b --- /dev/null +++ b/packages/browser-sdk/src/utils/responseError.ts @@ -0,0 +1,112 @@ +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 }; +} diff --git a/packages/browser-sdk/test/bulkQueue.test.ts b/packages/browser-sdk/test/bulkQueue.test.ts new file mode 100644 index 00000000..e3006297 --- /dev/null +++ b/packages/browser-sdk/test/bulkQueue.test.ts @@ -0,0 +1,292 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BulkEvent, BulkQueue } from "../src/bulkQueue"; + +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" }, +}; + +const lateTrackEvent: BulkEvent = { + type: "event", + userId: "u1", + companyId: "c1", + event: "late-clicked", + attributes: { source: "footer" }, +}; + +describe("BulkQueue", () => { + beforeEach(() => { + vi.useFakeTimers(); + sessionStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + sessionStorage.clear(); + }); + + 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, + }); + + 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, + }); + + 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("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("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) => { + 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 + .fn<(events: BulkEvent[]) => Promise>() + .mockImplementation( + () => + new Promise((resolve) => { + resolveSend = resolve; + }), + ); + const queue = new BulkQueue(sendBulk, { + flushDelayMs: 10_000, + maxSize: 2, + }); + + 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 queue state between instances in the same tab", async () => { + const firstSend = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockResolvedValue(new Response("", { status: 200 })); + const firstQueue = new BulkQueue(firstSend, { + flushDelayMs: 10_000, + }); + + 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, + }); + + expect(await secondQueue.size()).toBe(1); + await secondQueue.flush(); + expect(secondSend).toHaveBeenCalledWith([userEvent]); + }); + + 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 bf1fe228..382ea4ac 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -14,16 +14,24 @@ describe("ReflagClient", () => { const flagClientSetContext = vi.spyOn(FlagsClient.prototype, "setContext"); beforeEach(() => { + localStorage.clear(); + sessionStorage.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(); + sessionStorage.clear(); vi.unstubAllGlobals(); }); @@ -35,13 +43,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 +70,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 +97,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(); @@ -154,6 +196,24 @@ 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({ 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..5591344e 100644 --- a/packages/browser-sdk/test/init.test.ts +++ b/packages/browser-sdk/test/init.test.ts @@ -1,5 +1,13 @@ 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 +25,16 @@ const logger = { }; beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); vi.clearAllMocks(); }); +afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); +}); + describe("init", () => { test("will accept setup with key and debug logger", async () => { const reflagInstance = new ReflagClient({ @@ -33,6 +48,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 +67,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 +88,7 @@ describe("init", () => { expect(user).toHaveBeenCalled(); expect(company).toHaveBeenCalled(); + await reflagInstance.stop(); }); test("can disable tracking and auto. feedback surveys", async () => { @@ -88,6 +107,7 @@ describe("init", () => { await reflagInstance.track("test"); expect(post).not.toHaveBeenCalled(); + await reflagInstance.stop(); }); test("passes credentials correctly to httpClient", async () => { @@ -103,5 +123,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..274f311b 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -48,6 +48,11 @@ afterEach(() => { server.resetHandlers(); }); +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); +}); + describe("usage", () => { afterEach(() => { vi.clearAllMocks(); @@ -217,17 +222,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 }); + }), ); }); @@ -241,6 +256,9 @@ describe("feedback state management", () => { reflagInstance = new ReflagClient({ publishableKey: KEY, user: { id: "foo" }, + trackingQueue: { + flushDelayMs: 0, + }, feedback: { autoFeedbackHandler: callback, }, @@ -473,6 +491,9 @@ describe(`sends "check" events `, () => { publishableKey: KEY, user: { id: "uid" }, company: { id: "cid" }, + trackingQueue: { + flushDelayMs: 0, + }, }); await client.initialize(); @@ -494,25 +515,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 +554,9 @@ describe(`sends "check" events `, () => { const client = new ReflagClient({ publishableKey: KEY, user: { id: "uid" }, + trackingQueue: { + flushDelayMs: 0, + }, }); await client.initialize(); @@ -530,26 +565,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-sdk/dev/nextjs-bootstrap-demo/tsconfig.json b/packages/react-sdk/dev/nextjs-bootstrap-demo/tsconfig.json index e7ff90fd..9e9bbf7b 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/tsconfig.json +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -10,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -18,9 +22,20 @@ } ], "paths": { - "@/*": ["./*"] - } + "@/*": [ + "./*" + ] + }, + "target": "ES2017" }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/packages/react-sdk/dev/nextjs-flag-demo/tsconfig.json b/packages/react-sdk/dev/nextjs-flag-demo/tsconfig.json index e7ff90fd..d81d4ee1 100644 --- a/packages/react-sdk/dev/nextjs-flag-demo/tsconfig.json +++ b/packages/react-sdk/dev/nextjs-flag-demo/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -18,9 +22,19 @@ } ], "paths": { - "@/*": ["./*"] - } + "@/*": [ + "./*" + ] + }, + "target": "ES2017" }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 0c5633e9..2a6575a5 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -37,7 +37,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.4.3" + "@reflag/browser-sdk": "1.4.4" }, "peerDependencies": { "react": "*", diff --git a/packages/rest-api-sdk/examples/customer-admin-panel/next-env.d.ts b/packages/rest-api-sdk/examples/customer-admin-panel/next-env.d.ts index fd36f949..4f11a03d 100644 --- a/packages/rest-api-sdk/examples/customer-admin-panel/next-env.d.ts +++ b/packages/rest-api-sdk/examples/customer-admin-panel/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/vue-sdk/package.json b/packages/vue-sdk/package.json index b0894936..fb39999b 100644 --- a/packages/vue-sdk/package.json +++ b/packages/vue-sdk/package.json @@ -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 b095ef2f..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" @@ -5891,7 +5879,7 @@ __metadata: 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"