diff --git a/CHANGES.txt b/CHANGES.txt index 63a22deb..b6b6c585 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +2.11.0 (January XX, 2026) + - Added metadata to SDK_UPDATE events to indicate the type of update (FLAGS_UPDATE or SEGMENTS_UPDATE) and the names of updated flags or segments. + - Added metadata to SDK_READY and SDK_READY_FROM_CACHE events, including `initialCacheLoad` (boolean indicating if SDK was loaded from cache) and `lastUpdateTimestamp` (Int64 milliseconds since epoch). + 2.10.1 (December 18, 2025) - Bugfix - Handle `null` prerequisites properly. diff --git a/package-lock.json b/package-lock.json index 0afaae80..549025de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.10.1", + "version": "2.10.2-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.10.1", + "version": "2.10.2-rc.1", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index b003a6c4..1d983bcb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.10.1", + "version": "2.10.2-rc.1", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/readiness/__tests__/readinessManager.spec.ts b/src/readiness/__tests__/readinessManager.spec.ts index 34eaf9a3..6674e17e 100644 --- a/src/readiness/__tests__/readinessManager.spec.ts +++ b/src/readiness/__tests__/readinessManager.spec.ts @@ -3,6 +3,8 @@ import { EventEmitter } from '../../utils/MinEvents'; import { IReadinessManager } from '../types'; import { SDK_READY, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED, SDK_READY_FROM_CACHE, SDK_SPLITS_CACHE_LOADED, SDK_READY_TIMED_OUT } from '../constants'; import { ISettings } from '../../types'; +import { SdkUpdateMetadata, SdkReadyMetadata } from '../../../types/splitio'; +import { SdkUpdateMetadataKeys } from '../../sync/polling/types'; const settings = { startup: { @@ -300,3 +302,142 @@ test('READINESS MANAGER / Destroy before it was ready and timedout', (done) => { }, settingsWithTimeout.startup.readyTimeout * 1.5); }); + +test('READINESS MANAGER / SDK_UPDATE should emit with metadata', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + // SDK_READY + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + const metadata: SdkUpdateMetadata = { + type: SdkUpdateMetadataKeys.FLAGS_UPDATE, + names: ['flag1', 'flag2'] + }; + + let receivedMetadata: SdkUpdateMetadata | undefined; + readinessManager.gate.on(SDK_UPDATE, (meta: SdkUpdateMetadata) => { + receivedMetadata = meta; + }); + + readinessManager.splits.emit(SDK_SPLITS_ARRIVED, metadata); + + expect(receivedMetadata).toEqual(metadata); +}); + +test('READINESS MANAGER / SDK_UPDATE should handle undefined metadata', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + // SDK_READY + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + let receivedMetadata: any; + readinessManager.gate.on(SDK_UPDATE, (meta: SdkUpdateMetadata) => { + receivedMetadata = meta; + }); + + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + + expect(receivedMetadata).toBeUndefined(); +}); + +test('READINESS MANAGER / SDK_UPDATE should forward metadata from segments', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + // SDK_READY + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + const metadata: SdkUpdateMetadata = { + type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, + names: [] + }; + + let receivedMetadata: SdkUpdateMetadata | undefined; + readinessManager.gate.on(SDK_UPDATE, (meta: SdkUpdateMetadata) => { + receivedMetadata = meta; + }); + + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED, metadata); + + expect(receivedMetadata).toEqual(metadata); +}); + +test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when cache is loaded', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + let receivedMetadata: SdkReadyMetadata | undefined; + readinessManager.gate.on(SDK_READY_FROM_CACHE, (meta: SdkReadyMetadata) => { + receivedMetadata = meta; + }); + + // Emit cache loaded event + readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED); + + expect(receivedMetadata).toBeDefined(); + expect(receivedMetadata!.initialCacheLoad).toBe(true); + expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0); + // Allow small timing difference (up to 10ms) + expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10); +}); + +test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when SDK becomes ready without cache', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + let receivedMetadata: SdkReadyMetadata | undefined; + readinessManager.gate.on(SDK_READY_FROM_CACHE, (meta: SdkReadyMetadata) => { + receivedMetadata = meta; + }); + + // Make SDK ready without cache first + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + expect(receivedMetadata).toBeDefined(); + expect(receivedMetadata!.initialCacheLoad).toBe(false); + expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0); + // Allow small timing difference (up to 10ms) + expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10); +}); + +test('READINESS MANAGER / SDK_READY should emit with metadata when ready from cache', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + // First emit cache loaded + readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED); + + let receivedMetadata: SdkReadyMetadata | undefined; + readinessManager.gate.on(SDK_READY, (meta: SdkReadyMetadata) => { + receivedMetadata = meta; + }); + + // Make SDK ready + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + expect(receivedMetadata).toBeDefined(); + expect(receivedMetadata!.initialCacheLoad).toBe(true); // Was ready from cache first + expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0); + // Allow small timing difference (up to 10ms) + expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10); +}); + +test('READINESS MANAGER / SDK_READY should emit with metadata when ready without cache', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + let receivedMetadata: SdkReadyMetadata | undefined; + readinessManager.gate.on(SDK_READY, (meta: SdkReadyMetadata) => { + receivedMetadata = meta; + }); + + // Make SDK ready without cache + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + expect(receivedMetadata).toBeDefined(); + expect(receivedMetadata!.initialCacheLoad).toBe(false); // Was not ready from cache + expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0); + // Allow small timing difference (up to 10ms) + expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10); +}); diff --git a/src/readiness/readinessManager.ts b/src/readiness/readinessManager.ts index 319e843d..68fefc28 100644 --- a/src/readiness/readinessManager.ts +++ b/src/readiness/readinessManager.ts @@ -15,7 +15,7 @@ function splitsEventEmitterFactory(EventEmitter: new () => SplitIO.IEventEmitter // `isSplitKill` condition avoids an edge-case of wrongly emitting SDK_READY if: // - `/memberships` fetch and SPLIT_KILL occurs before `/splitChanges` fetch, and // - storage has cached splits (for which case `splitsStorage.killLocally` can return true) - splitsEventEmitter.on(SDK_SPLITS_ARRIVED, (isSplitKill: boolean) => { if (!isSplitKill) splitsEventEmitter.splitsArrived = true; }); + splitsEventEmitter.on(SDK_SPLITS_ARRIVED, (metadata: SplitIO.SdkUpdateMetadata, isSplitKill: boolean) => { if (!isSplitKill) splitsEventEmitter.splitsArrived = true; }); splitsEventEmitter.once(SDK_SPLITS_CACHE_LOADED, () => { splitsEventEmitter.splitsCacheLoaded = true; }); return splitsEventEmitter; @@ -90,7 +90,11 @@ export function readinessManagerFactory( if (!isReady && !isDestroyed) { try { syncLastUpdate(); - gate.emit(SDK_READY_FROM_CACHE, isReady); + const metadata: SplitIO.SdkReadyMetadata = { + initialCacheLoad: true, + lastUpdateTimestamp: lastUpdate + }; + gate.emit(SDK_READY_FROM_CACHE, metadata); } catch (e) { // throws user callback exceptions in next tick setTimeout(() => { throw e; }, 0); @@ -98,12 +102,12 @@ export function readinessManagerFactory( } } - function checkIsReadyOrUpdate(diff: any) { + function checkIsReadyOrUpdate(metadata: SplitIO.SdkUpdateMetadata) { if (isDestroyed) return; if (isReady) { try { syncLastUpdate(); - gate.emit(SDK_UPDATE, diff); + gate.emit(SDK_UPDATE, metadata); } catch (e) { // throws user callback exceptions in next tick setTimeout(() => { throw e; }, 0); @@ -114,11 +118,20 @@ export function readinessManagerFactory( isReady = true; try { syncLastUpdate(); + const wasReadyFromCache = isReadyFromCache; if (!isReadyFromCache) { isReadyFromCache = true; - gate.emit(SDK_READY_FROM_CACHE, isReady); + const metadataFromCache: SplitIO.SdkReadyMetadata = { + initialCacheLoad: false, + lastUpdateTimestamp: lastUpdate + }; + gate.emit(SDK_READY_FROM_CACHE, metadataFromCache); } - gate.emit(SDK_READY); + const metadataReady: SplitIO.SdkReadyMetadata = { + initialCacheLoad: wasReadyFromCache, + lastUpdateTimestamp: lastUpdate + }; + gate.emit(SDK_READY, metadataReady); } catch (e) { // throws user callback exceptions in next tick setTimeout(() => { throw e; }, 0); diff --git a/src/readiness/types.ts b/src/readiness/types.ts index 2de99b43..03ac2a0f 100644 --- a/src/readiness/types.ts +++ b/src/readiness/types.ts @@ -1,5 +1,30 @@ import SplitIO from '../../types/splitio'; +/** Readiness event types */ + +export type SDK_READY_TIMED_OUT = 'init::timeout' +export type SDK_READY = 'init::ready' +export type SDK_READY_FROM_CACHE = 'init::cache-ready' +export type SDK_UPDATE = 'state::update' +export type SDK_DESTROY = 'state::destroy' + +export type IReadinessEvent = SDK_READY_TIMED_OUT | SDK_READY | SDK_READY_FROM_CACHE | SDK_UPDATE | SDK_DESTROY + +export interface IReadinessEventEmitter extends SplitIO.IEventEmitter { + emit(event: IReadinessEvent, ...args: any[]): boolean + on(event: SDK_READY, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + on(event: SDK_READY_FROM_CACHE, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; + on(event: string | symbol, listener: (...args: any[]) => void): this; + once(event: SDK_READY, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + once(event: SDK_READY_FROM_CACHE, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + once(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; + once(event: string | symbol, listener: (...args: any[]) => void): this; + addListener(event: SDK_READY, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + addListener(event: SDK_READY_FROM_CACHE, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + addListener(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; + addListener(event: string | symbol, listener: (...args: any[]) => void): this; +} /** Splits data emitter */ type SDK_SPLITS_ARRIVED = 'state::splits-arrived' @@ -9,6 +34,7 @@ type ISplitsEvent = SDK_SPLITS_ARRIVED | SDK_SPLITS_CACHE_LOADED export interface ISplitsEventEmitter extends SplitIO.IEventEmitter { emit(event: ISplitsEvent, ...args: any[]): boolean on(event: ISplitsEvent, listener: (...args: any[]) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; once(event: ISplitsEvent, listener: (...args: any[]) => void): this; splitsArrived: boolean splitsCacheLoaded: boolean @@ -24,23 +50,11 @@ type ISegmentsEvent = SDK_SEGMENTS_ARRIVED export interface ISegmentsEventEmitter extends SplitIO.IEventEmitter { emit(event: ISegmentsEvent, ...args: any[]): boolean on(event: ISegmentsEvent, listener: (...args: any[]) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; once(event: ISegmentsEvent, listener: (...args: any[]) => void): this; segmentsArrived: boolean } -/** Readiness emitter */ - -export type SDK_READY_TIMED_OUT = 'init::timeout' -export type SDK_READY = 'init::ready' -export type SDK_READY_FROM_CACHE = 'init::cache-ready' -export type SDK_UPDATE = 'state::update' -export type SDK_DESTROY = 'state::destroy' -export type IReadinessEvent = SDK_READY_TIMED_OUT | SDK_READY | SDK_READY_FROM_CACHE | SDK_UPDATE | SDK_DESTROY - -export interface IReadinessEventEmitter extends SplitIO.IEventEmitter { - emit(event: IReadinessEvent, ...args: any[]): boolean -} - /** Readiness manager */ export interface IReadinessManager { diff --git a/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts b/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts index fb80ffce..f446e7d2 100644 --- a/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts +++ b/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts @@ -10,78 +10,88 @@ const latencyKey = `${prefix}.telemetry.latencies`; const initKey = `${prefix}.telemetry.init`; const fieldVersionablePrefix = `${metadata.s}/${metadata.n}/${metadata.i}`; -test('TELEMETRY CACHE IN REDIS', async () => { +describe('TELEMETRY CACHE IN REDIS', () => { + let connection: RedisAdapter; + let cache: TelemetryCacheInRedis; + let keysBuilder: KeyBuilderSS; - const keysBuilder = new KeyBuilderSS(prefix, metadata); - const connection = new RedisAdapter(loggerMock); - const cache = new TelemetryCacheInRedis(loggerMock, keysBuilder, connection); + beforeEach(async () => { + keysBuilder = new KeyBuilderSS(prefix, metadata); + connection = new RedisAdapter(loggerMock); + cache = new TelemetryCacheInRedis(loggerMock, keysBuilder, connection); - // recordException - expect(await cache.recordException('tr')).toBe(1); - expect(await cache.recordException('tr')).toBe(2); - expect(await cache.recordException('tcfs')).toBe(1); + await connection.del(exceptionKey); + await connection.del(latencyKey); + await connection.del(initKey); + }); - expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/track')).toBe('2'); - expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/treatment')).toBe(null); - expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/treatmentsWithConfigByFlagSets')).toBe('1'); + test('TELEMETRY CACHE IN REDIS', async () => { - // recordLatency - expect(await cache.recordLatency('tr', 1.6)).toBe(1); - expect(await cache.recordLatency('tr', 1.6)).toBe(2); - expect(await cache.recordLatency('tfs', 1.6)).toBe(1); + // recordException + expect(await cache.recordException('tr')).toBe(1); + expect(await cache.recordException('tr')).toBe(2); + expect(await cache.recordException('tcfs')).toBe(1); - expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/track/2')).toBe('2'); - expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/treatment/2')).toBe(null); - expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/treatmentsByFlagSets/2')).toBe('1'); + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/track')).toBe('2'); + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/treatment')).toBe(null); + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/treatmentsWithConfigByFlagSets')).toBe('1'); - // recordConfig - expect(await cache.recordConfig()).toBe(1); - expect(JSON.parse(await connection.hget(initKey, fieldVersionablePrefix) as string)).toEqual({ - oM: 1, - st: 'redis', - aF: 0, - rF: 0 - }); + // recordLatency + expect(await cache.recordLatency('tr', 1.6)).toBe(1); + expect(await cache.recordLatency('tr', 1.6)).toBe(2); + expect(await cache.recordLatency('tfs', 1.6)).toBe(1); - // popLatencies - const latencies = await cache.popLatencies(); - latencies.forEach((latency, m) => { - expect(JSON.parse(m)).toEqual(metadata); - expect(latency).toEqual({ - tfs: [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - tr: [0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - }); - }); - expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/track/2')).toBe(null); - - // popExceptions - const exceptions = await cache.popExceptions(); - exceptions.forEach((exception, m) => { - expect(JSON.parse(m)).toEqual(metadata); - expect(exception).toEqual({ - tcfs: 1, - tr: 2, - }); - }); - expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/track')).toBe(null); + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/track/2')).toBe('2'); + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/treatment/2')).toBe(null); + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/treatmentsByFlagSets/2')).toBe('1'); - // popConfig - const configs = await cache.popConfigs(); - configs.forEach((config, m) => { - expect(JSON.parse(m)).toEqual(metadata); - expect(config).toEqual({ + // recordConfig + expect(await cache.recordConfig()).toBe(1); + expect(JSON.parse(await connection.hget(initKey, fieldVersionablePrefix) as string)).toEqual({ oM: 1, st: 'redis', aF: 0, rF: 0 }); - }); - expect(await connection.hget(initKey, fieldVersionablePrefix)).toBe(null); - // pops when there is no data - expect((await cache.popLatencies()).size).toBe(0); - expect((await cache.popExceptions()).size).toBe(0); - expect((await cache.popConfigs()).size).toBe(0); + // popLatencies + const latencies = await cache.popLatencies(); + latencies.forEach((latency, m) => { + expect(JSON.parse(m)).toEqual(metadata); + expect(latency).toEqual({ + tfs: [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + tr: [0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }); + }); + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/track/2')).toBe(null); + + // popExceptions + const exceptions = await cache.popExceptions(); + exceptions.forEach((exception, m) => { + expect(JSON.parse(m)).toEqual(metadata); + expect(exception).toEqual({ + tcfs: 1, + tr: 2, + }); + }); + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/track')).toBe(null); - await connection.disconnect(); + // popConfig + const configs = await cache.popConfigs(); + configs.forEach((config, m) => { + expect(JSON.parse(m)).toEqual(metadata); + expect(config).toEqual({ + oM: 1, + st: 'redis', + aF: 0, + rF: 0 + }); + }); + expect(await connection.hget(initKey, fieldVersionablePrefix)).toBe(null); + + // pops when there is no data + expect((await cache.popLatencies()).size).toBe(0); + expect((await cache.popExceptions()).size).toBe(0); + expect((await cache.popConfigs()).size).toBe(0); + }); }); diff --git a/src/sync/offline/syncTasks/fromObjectSyncTask.ts b/src/sync/offline/syncTasks/fromObjectSyncTask.ts index 96bc8384..08da854b 100644 --- a/src/sync/offline/syncTasks/fromObjectSyncTask.ts +++ b/src/sync/offline/syncTasks/fromObjectSyncTask.ts @@ -9,6 +9,7 @@ import { ISettings } from '../../../types'; import { CONTROL } from '../../../utils/constants'; import { SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED, SDK_SPLITS_CACHE_LOADED } from '../../../readiness/constants'; import { SYNC_OFFLINE_DATA, ERROR_SYNC_OFFLINE_LOADING } from '../../../logger/constants'; +import { SdkUpdateMetadataKeys } from '../../polling/types'; /** * Offline equivalent of `splitChangesUpdaterFactory` @@ -55,7 +56,7 @@ export function fromObjectUpdaterFactory( splitsCache.clear(), // required to sync removed splits from mock splitsCache.update(splits, [], Date.now()) ]).then(() => { - readiness.splits.emit(SDK_SPLITS_ARRIVED); + readiness.splits.emit(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: [] }); if (startingUp) { startingUp = false; @@ -63,7 +64,7 @@ export function fromObjectUpdaterFactory( // Emits SDK_READY_FROM_CACHE if (isCacheLoaded) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); // Emits SDK_READY - readiness.segments.emit(SDK_SEGMENTS_ARRIVED); + readiness.segments.emit(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); }); } return true; diff --git a/src/sync/polling/pollingManagerCS.ts b/src/sync/polling/pollingManagerCS.ts index 5e197e62..5c1169d3 100644 --- a/src/sync/polling/pollingManagerCS.ts +++ b/src/sync/polling/pollingManagerCS.ts @@ -9,6 +9,7 @@ import { SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED } from '../../readiness/consta import { POLLING_SMART_PAUSING, POLLING_START, POLLING_STOP } from '../../logger/constants'; import { ISdkFactoryContextSync } from '../../sdkFactory/types'; import { usesSegmentsSync } from '../../storages/AbstractSplitsCacheSync'; +import { SdkUpdateMetadata } from '../../../types/splitio'; /** * Expose start / stop mechanism for polling data from services. @@ -59,8 +60,8 @@ export function pollingManagerCSFactory( const mySegmentsSyncTask = mySegmentsSyncTaskFactory(splitApi.fetchMemberships, storage, readiness, settings, matchingKey); // smart ready - function smartReady() { - if (!readiness.isReady() && !usesSegmentsSync(storage)) readiness.segments.emit(SDK_SEGMENTS_ARRIVED); + function smartReady(metadata: SdkUpdateMetadata) { + if (!readiness.isReady() && !usesSegmentsSync(storage)) readiness.segments.emit(SDK_SEGMENTS_ARRIVED, metadata); } if (!usesSegmentsSync(storage)) setTimeout(smartReady, 0); else readiness.splits.once(SDK_SPLITS_ARRIVED, smartReady); diff --git a/src/sync/polling/types.ts b/src/sync/polling/types.ts index 4ff29c83..5823488a 100644 --- a/src/sync/polling/types.ts +++ b/src/sync/polling/types.ts @@ -4,6 +4,20 @@ import { IStorageSync } from '../../storages/types'; import { MEMBERSHIPS_LS_UPDATE, MEMBERSHIPS_MS_UPDATE } from '../streaming/types'; import { ITask, ISyncTask } from '../types'; +/** + * Metadata keys for SDK update events. + */ +export enum SdkUpdateMetadataKeys { + /** + * The update event emitted when the SDK cache is updated with new data for flags. + */ + FLAGS_UPDATE = 'FLAGS_UPDATE', + /** + * The update event emitted when the SDK cache is updated with new data for segments. + */ + SEGMENTS_UPDATE = 'SEGMENTS_UPDATE' +} + export interface ISplitsSyncTask extends ISyncTask<[noCache?: boolean, till?: number, splitUpdateNotification?: { payload: ISplit | IRBSegment, changeNumber: number }], boolean> { } export interface ISegmentsSyncTask extends ISyncTask<[fetchOnlyNew?: boolean, segmentName?: string, noCache?: boolean, till?: number], boolean> { } diff --git a/src/sync/polling/updaters/__tests__/mySegmentsUpdater.spec.ts b/src/sync/polling/updaters/__tests__/mySegmentsUpdater.spec.ts new file mode 100644 index 00000000..d35ae6f0 --- /dev/null +++ b/src/sync/polling/updaters/__tests__/mySegmentsUpdater.spec.ts @@ -0,0 +1,108 @@ +import { readinessManagerFactory } from '../../../../readiness/readinessManager'; +import { MySegmentsCacheInMemory } from '../../../../storages/inMemory/MySegmentsCacheInMemory'; +import { mySegmentsUpdaterFactory } from '../mySegmentsUpdater'; +import { fullSettings } from '../../../../utils/settingsValidation/__tests__/settings.mocks'; +import { EventEmitter } from '../../../../utils/MinEvents'; +import { loggerMock } from '../../../../logger/__tests__/sdkLogger.mock'; +import { IMySegmentsFetcher } from '../../fetchers/types'; +import { IMembershipsResponse } from '../../../../dtos/types'; +import { SDK_SEGMENTS_ARRIVED } from '../../../../readiness/constants'; +import { SdkUpdateMetadataKeys } from '../../types'; +import { MySegmentsData } from '../../types'; +import { MEMBERSHIPS_MS_UPDATE } from '../../../streaming/constants'; +import { IStorageSync } from '../../../../storages/types'; +import { SplitsCacheInMemory } from '../../../../storages/inMemory/SplitsCacheInMemory'; +import { RBSegmentsCacheInMemory } from '../../../../storages/inMemory/RBSegmentsCacheInMemory'; + +describe('mySegmentsUpdater', () => { + const segments = new MySegmentsCacheInMemory(); + const largeSegments = new MySegmentsCacheInMemory(); + const splits = new SplitsCacheInMemory(); + const rbSegments = new RBSegmentsCacheInMemory(); + const storage: IStorageSync = { + segments, + largeSegments, + splits, + rbSegments, + impressions: {} as any, + events: {} as any, + impressionCounts: {} as any, + telemetry: undefined, + uniqueKeys: {} as any, + save: () => {}, + destroy: () => {} + }; + const readinessManager = readinessManagerFactory(EventEmitter, fullSettings); + const segmentsEmitSpy = jest.spyOn(readinessManager.segments, 'emit'); + + beforeEach(() => { + jest.clearAllMocks(); + storage.segments.clear(); + readinessManager.segments.segmentsArrived = false; + }); + + test('test with mySegments update - should emit SEGMENTS_UPDATE metadata', async () => { + const mockMySegmentsFetcher: IMySegmentsFetcher = jest.fn().mockResolvedValue({ + ms: { 'segment1': true, 'segment2': true }, + ls: {} + } as IMembershipsResponse); + + const mySegmentsUpdater = mySegmentsUpdaterFactory( + loggerMock, + mockMySegmentsFetcher, + storage, + readinessManager.segments, + 1000, + 1, + 'test-key' + ); + + await mySegmentsUpdater(); + + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with mySegments data payload - should emit SEGMENTS_UPDATE metadata', async () => { + const segmentsData: MySegmentsData = { + type: MEMBERSHIPS_MS_UPDATE, + cn: 123, + added: ['segment1', 'segment2'], + removed: [] + }; + + const mySegmentsUpdater = mySegmentsUpdaterFactory( + loggerMock, + jest.fn().mockResolvedValue({ ms: {}, ls: {} } as IMembershipsResponse), + storage, + readinessManager.segments, + 1000, + 1, + 'test-key' + ); + + await mySegmentsUpdater(segmentsData); + + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with empty mySegments - should still emit SEGMENTS_UPDATE metadata', async () => { + const mockMySegmentsFetcher: IMySegmentsFetcher = jest.fn().mockResolvedValue({ + ms: {}, + ls: {} + } as IMembershipsResponse); + + const mySegmentsUpdater = mySegmentsUpdaterFactory( + loggerMock, + mockMySegmentsFetcher, + storage, + readinessManager.segments, + 1000, + 1, + 'test-key' + ); + + await mySegmentsUpdater(); + + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); +}); diff --git a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts new file mode 100644 index 00000000..1ff9f845 --- /dev/null +++ b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts @@ -0,0 +1,135 @@ +import { readinessManagerFactory } from '../../../../readiness/readinessManager'; +import { SegmentsCacheInMemory } from '../../../../storages/inMemory/SegmentsCacheInMemory'; +import { segmentChangesUpdaterFactory } from '../segmentChangesUpdater'; +import { fullSettings } from '../../../../utils/settingsValidation/__tests__/settings.mocks'; +import { EventEmitter } from '../../../../utils/MinEvents'; +import { loggerMock } from '../../../../logger/__tests__/sdkLogger.mock'; +import { ISegmentChangesFetcher } from '../../fetchers/types'; +import { ISegmentChangesResponse } from '../../../../dtos/types'; +import { SDK_SEGMENTS_ARRIVED } from '../../../../readiness/constants'; +import { SdkUpdateMetadataKeys } from '../../types'; + +describe('segmentChangesUpdater', () => { + const segments = new SegmentsCacheInMemory(); + const updateSegments = jest.spyOn(segments, 'update'); + + const readinessManager = readinessManagerFactory(EventEmitter, fullSettings); + const segmentsEmitSpy = jest.spyOn(readinessManager.segments, 'emit'); + + beforeEach(() => { + jest.clearAllMocks(); + segments.clear(); + readinessManager.segments.segmentsArrived = false; + }); + + test('test with segments update - should emit updatedSegments and NOT updatedFlags', async () => { + const segmentName = 'test-segment'; + const segmentChange: ISegmentChangesResponse = { + name: segmentName, + added: ['key1', 'key2'], + removed: [], + since: -1, + till: 123 + }; + + const mockSegmentChangesFetcher: ISegmentChangesFetcher = jest.fn().mockResolvedValue([segmentChange]); + + const segmentChangesUpdater = segmentChangesUpdaterFactory( + loggerMock, + mockSegmentChangesFetcher, + segments, + readinessManager, + 1000, + 1 + ); + + segments.registerSegments([segmentName]); + + await segmentChangesUpdater(undefined, segmentName); + + expect(updateSegments).toHaveBeenCalledWith(segmentName, segmentChange.added, segmentChange.removed, segmentChange.till); + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with multiple segments update - should emit SEGMENTS_UPDATE metadata once', async () => { + const segment1 = 'segment1'; + const segment2 = 'segment2'; + const segment3 = 'segment3'; + + const segmentChange1: ISegmentChangesResponse = { + name: segment1, + added: ['key1'], + removed: [], + since: -1, + till: 100 + }; + + const segmentChange2: ISegmentChangesResponse = { + name: segment2, + added: ['key2'], + removed: [], + since: -1, + till: 101 + }; + + const segmentChange3: ISegmentChangesResponse = { + name: segment3, + added: ['key3'], + removed: [], + since: -1, + till: 102 + }; + + const mockSegmentChangesFetcher: ISegmentChangesFetcher = jest.fn().mockResolvedValue([ + segmentChange1, + segmentChange2, + segmentChange3 + ]); + + const segmentChangesUpdater = segmentChangesUpdaterFactory( + loggerMock, + mockSegmentChangesFetcher, + segments, + readinessManager, + 1000, + 1 + ); + + segments.registerSegments([segment1, segment2, segment3]); + + // Update all segments at once + await segmentChangesUpdater(undefined); + + // Should emit once when all segments are updated + expect(segmentsEmitSpy).toHaveBeenCalledTimes(1); + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with empty segments - should still emit SEGMENTS_UPDATE metadata', async () => { + const segmentName = 'empty-segment'; + const segmentChange: ISegmentChangesResponse = { + name: segmentName, + added: [], + removed: [], + since: -1, + till: 123 + }; + + const mockSegmentChangesFetcher: ISegmentChangesFetcher = jest.fn().mockResolvedValue([segmentChange]); + + const segmentChangesUpdater = segmentChangesUpdaterFactory( + loggerMock, + mockSegmentChangesFetcher, + segments, + readinessManager, + 1000, + 1 + ); + + segments.registerSegments([segmentName]); + + await segmentChangesUpdater(undefined, segmentName); + + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); +}); diff --git a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts index b93a7176..21b0af8c 100644 --- a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts @@ -15,6 +15,8 @@ import { splitNotifications } from '../../../streaming/__tests__/dataMocks'; import { RBSegmentsCacheInMemory } from '../../../../storages/inMemory/RBSegmentsCacheInMemory'; import { RB_SEGMENT_UPDATE, SPLIT_UPDATE } from '../../../streaming/constants'; import { IN_RULE_BASED_SEGMENT } from '../../../../utils/constants'; +import { SDK_SPLITS_ARRIVED } from '../../../../readiness/constants'; +import { SdkUpdateMetadataKeys } from '../../types'; const ARCHIVED_FF = 'ARCHIVED'; @@ -120,6 +122,7 @@ test('splitChangesUpdater / compute splits mutation', () => { expect(splitsMutation.added).toEqual([activeSplitWithSegments]); expect(splitsMutation.removed).toEqual([archivedSplit]); + expect(splitsMutation.names).toEqual([activeSplitWithSegments.name, archivedSplit.name]); expect(Array.from(segments)).toEqual(['A', 'B']); // SDK initialization without sets @@ -129,6 +132,7 @@ test('splitChangesUpdater / compute splits mutation', () => { expect(splitsMutation.added).toEqual([testFFSetsAB, test2FFSetsX]); expect(splitsMutation.removed).toEqual([]); + expect(splitsMutation.names).toEqual([testFFSetsAB.name, test2FFSetsX.name]); expect(Array.from(segments)).toEqual([]); }); @@ -142,24 +146,28 @@ test('splitChangesUpdater / compute splits mutation with filters', () => { // should add it to mutations expect(splitsMutation.added).toEqual([testFFSetsAB]); expect(splitsMutation.removed).toEqual([]); + expect(splitsMutation.names).toEqual([testFFSetsAB.name]); // fetching existing test feature flag removed from set B splitsMutation = computeMutation([testFFRemoveSetB], new Set(), splitFiltersValidation); expect(splitsMutation.added).toEqual([testFFRemoveSetB]); expect(splitsMutation.removed).toEqual([]); + expect(splitsMutation.names).toEqual([testFFRemoveSetB.name]); // fetching existing test feature flag removed from set B splitsMutation = computeMutation([testFFRemoveSetA], new Set(), splitFiltersValidation); expect(splitsMutation.added).toEqual([]); expect(splitsMutation.removed).toEqual([testFFRemoveSetA]); + expect(splitsMutation.names).toEqual([testFFRemoveSetA.name]); // fetching existing test feature flag removed from set B splitsMutation = computeMutation([testFFEmptySet], new Set(), splitFiltersValidation); expect(splitsMutation.added).toEqual([]); expect(splitsMutation.removed).toEqual([testFFEmptySet]); + expect(splitsMutation.names).toEqual([testFFEmptySet.name]); // SDK initialization with names: ['test2'] splitFiltersValidation = { queryString: '&names=test2', groupedFilters: { bySet: [], byName: ['test2'], byPrefix: [] }, validFilters: [] }; @@ -167,11 +175,13 @@ test('splitChangesUpdater / compute splits mutation with filters', () => { expect(splitsMutation.added).toEqual([]); expect(splitsMutation.removed).toEqual([testFFSetsAB]); + expect(splitsMutation.names).toEqual([testFFSetsAB.name]); splitsMutation = computeMutation([test2FFSetsX, testFFEmptySet], new Set(), splitFiltersValidation); expect(splitsMutation.added).toEqual([test2FFSetsX]); expect(splitsMutation.removed).toEqual([testFFEmptySet]); + expect(splitsMutation.names).toEqual([test2FFSetsX.name, testFFEmptySet.name]); }); describe('splitChangesUpdater', () => { @@ -204,6 +214,7 @@ describe('splitChangesUpdater', () => { test('test without payload', async () => { const result = await splitChangesUpdater(); + const updatedFlags = splitChangesMock1.ff.d.map(ff => ff.name); expect(fetchSplitChanges).toBeCalledTimes(1); expect(fetchSplitChanges).lastCalledWith(-1, undefined, undefined, -1); @@ -211,7 +222,7 @@ describe('splitChangesUpdater', () => { expect(updateSplits).lastCalledWith(splitChangesMock1.ff.d, [], splitChangesMock1.ff.t); expect(updateRbSegments).toBeCalledTimes(0); // no rbSegments to update expect(registerSegments).toBeCalledTimes(1); - expect(splitsEmitSpy).toBeCalledWith('state::splits-arrived'); + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: updatedFlags }); expect(result).toBe(true); }); @@ -276,7 +287,8 @@ describe('splitChangesUpdater', () => { // emit always if not configured sets for (const setMock of setMocks) { await expect(splitChangesUpdater(undefined, undefined, { payload: { ...payload, sets: setMock.sets, status: 'ACTIVE' }, changeNumber: index, type: SPLIT_UPDATE })).resolves.toBe(true); - expect(splitsEmitSpy.mock.calls[index][0]).toBe('state::splits-arrived'); + expect(splitsEmitSpy.mock.calls[index][0]).toBe(SDK_SPLITS_ARRIVED); + expect(splitsEmitSpy.mock.calls[index][1]).toEqual({ type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: [payload.name] }); index++; } @@ -294,4 +306,123 @@ describe('splitChangesUpdater', () => { } }); + + test('test with ff payload - should emit metadata with flag name', async () => { + splitsEmitSpy.mockClear(); + + readinessManager.splits.splitsArrived = false; + storage.splits.clear(); + + const payload = splitNotifications[0].decoded as Pick; + const changeNumber = payload.changeNumber; + + await expect(splitChangesUpdater(undefined, undefined, { payload, changeNumber: changeNumber, type: SPLIT_UPDATE })).resolves.toBe(true); + + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: [payload.name] }); + }); + + test('test with multiple flags updated - should emit metadata with all flag names', async () => { + splitsEmitSpy.mockClear(); + storage.splits.clear(); + storage.segments.clear(); + // Start with splitsArrived = false so it emits on first update + readinessManager.splits.splitsArrived = false; + readinessManager.segments.segmentsArrived = true; // Segments ready + + const flag1 = { name: 'flag1', status: 'ACTIVE', changeNumber: 100, conditions: [] } as unknown as ISplit; + const flag2 = { name: 'flag2', status: 'ACTIVE', changeNumber: 101, conditions: [] } as unknown as ISplit; + const flag3 = { name: 'flag3', status: 'ACTIVE', changeNumber: 102, conditions: [] } as unknown as ISplit; + + fetchMock.once('*', { status: 200, body: { ff: { d: [flag1, flag2, flag3], t: 102 } } }); + await splitChangesUpdater(); + + // Should emit with metadata when splitsArrived is false (first update) + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: ['flag1', 'flag2', 'flag3'] }); + }); + + test('test with ARCHIVED flag - should emit metadata with flag name', async () => { + splitsEmitSpy.mockClear(); + storage.splits.clear(); + storage.segments.clear(); + // Start with splitsArrived = false so it emits on first update + readinessManager.splits.splitsArrived = false; + readinessManager.segments.segmentsArrived = true; // Segments ready + + const archivedFlag = { name: 'archived-flag', status: ARCHIVED_FF, changeNumber: 200, conditions: [] } as unknown as ISplit; + + const payload = archivedFlag as Pick; + const changeNumber = payload.changeNumber; + + await expect(splitChangesUpdater(undefined, undefined, { payload, changeNumber: changeNumber, type: SPLIT_UPDATE })).resolves.toBe(true); + + // Should emit with metadata when splitsArrived is false (first update) + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: [payload.name] }); + }); + + test('test with rbsegment payload - should emit SEGMENTS_UPDATE not FLAGS_UPDATE', async () => { + splitsEmitSpy.mockClear(); + readinessManager.splits.splitsArrived = true; + storage.rbSegments.clear(); + + const payload = { name: 'rbsegment', status: 'ACTIVE', changeNumber: 1684329854385, conditions: [] } as unknown as IRBSegment; + const changeNumber = payload.changeNumber; + + await expect(splitChangesUpdater(undefined, undefined, { payload, changeNumber: changeNumber, type: RB_SEGMENT_UPDATE })).resolves.toBe(true); + + // Should emit SEGMENTS_UPDATE (not FLAGS_UPDATE) when only RB segment is updated + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with only RB segment update and no flags - should emit SEGMENTS_UPDATE', async () => { + splitsEmitSpy.mockClear(); + readinessManager.splits.splitsArrived = true; + storage.splits.clear(); + storage.rbSegments.clear(); + + // Simulate a scenario where only RB segments are updated (no flags) + const rbSegment = { name: 'rbsegment', status: 'ACTIVE', changeNumber: 1684329854385, conditions: [] } as unknown as IRBSegment; + fetchMock.once('*', { status: 200, body: { rbs: { d: [rbSegment], t: 1684329854385 } } }); + await splitChangesUpdater(); + + // When updatedFlags.length === 0, should emit SEGMENTS_UPDATE + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with both flags and RB segments updated - should emit FLAGS_UPDATE with flag names', async () => { + splitsEmitSpy.mockClear(); + readinessManager.splits.splitsArrived = true; + storage.splits.clear(); + storage.rbSegments.clear(); + storage.segments.clear(); + + // Simulate a scenario where both flags and RB segments are updated + const flag1 = { name: 'flag1', status: 'ACTIVE', changeNumber: 400, conditions: [] } as unknown as ISplit; + const flag2 = { name: 'flag2', status: 'ACTIVE', changeNumber: 401, conditions: [] } as unknown as ISplit; + const rbSegment = { name: 'rbsegment', status: 'ACTIVE', changeNumber: 1684329854385, conditions: [] } as unknown as IRBSegment; + + fetchMock.once('*', { status: 200, body: { ff: { d: [flag1, flag2], t: 401 }, rbs: { d: [rbSegment], t: 1684329854385 } } }); + await splitChangesUpdater(); + + // When both flags and RB segments are updated, should emit FLAGS_UPDATE with flag names + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: ['flag1', 'flag2'] }); + }); + + test('test client-side behavior - should emit even when segments not all fetched', async () => { + splitsEmitSpy.mockClear(); + storage.splits.clear(); + // Start with splitsArrived = false so it emits on first update + readinessManager.splits.splitsArrived = false; + readinessManager.segments.segmentsArrived = false; // Segments not ready - client-side should still emit + + // Create client-side updater (isClientSide = true) + const clientSideUpdater = splitChangesUpdaterFactory(loggerMock, splitChangesFetcher, storage, splitFiltersValidation, readinessManager.splits, 1000, 1, true); + + const flag1 = { name: 'client-flag', status: 'ACTIVE', changeNumber: 300, conditions: [] } as unknown as ISplit; + fetchMock.once('*', { status: 200, body: { ff: { d: [flag1], t: 300 } } }); + await clientSideUpdater(); + + // Client-side should emit even if segments aren't all fetched (isClientSide bypasses checkAllSegmentsExist) + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: ['client-flag'] }); + }); + }); diff --git a/src/sync/polling/updaters/mySegmentsUpdater.ts b/src/sync/polling/updaters/mySegmentsUpdater.ts index 5421d3f9..7109cf47 100644 --- a/src/sync/polling/updaters/mySegmentsUpdater.ts +++ b/src/sync/polling/updaters/mySegmentsUpdater.ts @@ -9,6 +9,7 @@ import { MySegmentsData } from '../types'; import { IMembershipsResponse } from '../../../dtos/types'; import { MEMBERSHIPS_LS_UPDATE } from '../../streaming/constants'; import { usesSegmentsSync } from '../../../storages/AbstractSplitsCacheSync'; +import { SdkUpdateMetadataKeys } from '../types'; type IMySegmentsUpdater = (segmentsData?: MySegmentsData, noCache?: boolean, till?: number) => Promise @@ -56,7 +57,7 @@ export function mySegmentsUpdaterFactory( // Notify update if required if (usesSegmentsSync(storage) && (shouldNotifyUpdate || readyOnAlreadyExistentState)) { readyOnAlreadyExistentState = false; - segmentsEventEmitter.emit(SDK_SEGMENTS_ARRIVED); + segmentsEventEmitter.emit(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); } } diff --git a/src/sync/polling/updaters/segmentChangesUpdater.ts b/src/sync/polling/updaters/segmentChangesUpdater.ts index 8127a96c..ff8364c0 100644 --- a/src/sync/polling/updaters/segmentChangesUpdater.ts +++ b/src/sync/polling/updaters/segmentChangesUpdater.ts @@ -5,6 +5,9 @@ import { SDK_SEGMENTS_ARRIVED } from '../../../readiness/constants'; import { ILogger } from '../../../logger/types'; import { LOG_PREFIX_INSTANTIATION, LOG_PREFIX_SYNC_SEGMENTS } from '../../../logger/constants'; import { timeout } from '../../../utils/promise/timeout'; +import { SdkUpdateMetadata } from '../../../../types/splitio'; +import { SdkUpdateMetadataKeys } from '../types'; + type ISegmentChangesUpdater = (fetchOnlyNew?: boolean, segmentName?: string, noCache?: boolean, till?: number) => Promise @@ -83,7 +86,11 @@ export function segmentChangesUpdaterFactory( // if at least one segment fetch succeeded, mark segments ready if (shouldUpdateFlags.some(update => update) || readyOnAlreadyExistentState) { readyOnAlreadyExistentState = false; - if (readiness) readiness.segments.emit(SDK_SEGMENTS_ARRIVED); + const metadata: SdkUpdateMetadata = { + type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, + names: [] + }; + if (readiness) readiness.segments.emit(SDK_SEGMENTS_ARRIVED, metadata); } return true; }); diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 3a1fc5a7..45c5a8d5 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -10,6 +10,8 @@ import { startsWith } from '../../../utils/lang'; import { IN_RULE_BASED_SEGMENT, IN_SEGMENT, RULE_BASED_SEGMENT, STANDARD_SEGMENT } from '../../../utils/constants'; import { setToArray } from '../../../utils/lang/sets'; import { SPLIT_UPDATE } from '../../streaming/constants'; +import { SdkUpdateMetadata } from '../../../../types/splitio'; +import { SdkUpdateMetadataKeys } from '../types'; export type InstantUpdate = { payload: ISplit | IRBSegment, changeNumber: number, type: string }; type SplitChangesUpdater = (noCache?: boolean, till?: number, instantUpdate?: InstantUpdate) => Promise @@ -54,7 +56,8 @@ export function parseSegments(ruleEntity: ISplit | IRBSegment, matcherType: type interface ISplitMutations { added: T[], - removed: T[] + removed: T[], + names: string[] } /** @@ -95,9 +98,10 @@ export function computeMutation(rules: Array, } else { accum.removed.push(ruleEntity); } + accum.names.push(ruleEntity.name); return accum; - }, { added: [], removed: [] } as ISplitMutations); + }, { added: [], removed: [], names: [] } as ISplitMutations); } /** @@ -165,9 +169,11 @@ export function splitChangesUpdaterFactory( .then((splitChanges: ISplitChangesResponse) => { const usedSegments = new Set(); + let updatedFlags: string[] = []; let ffUpdate: MaybeThenable = false; if (splitChanges.ff) { - const { added, removed } = computeMutation(splitChanges.ff.d, usedSegments, splitFiltersValidation); + const { added, removed, names } = computeMutation(splitChanges.ff.d, usedSegments, splitFiltersValidation); + updatedFlags = names; log.debug(SYNC_SPLITS_UPDATE, [added.length, removed.length]); ffUpdate = splits.update(added, removed, splitChanges.ff.t); } @@ -192,8 +198,12 @@ export function splitChangesUpdaterFactory( return Promise.resolve(!splitsEventEmitter.splitsArrived || ((ffChanged || rbsChanged) && (isClientSide || checkAllSegmentsExist(segments)))) .catch(() => false /** noop. just to handle a possible `checkAllSegmentsExist` rejection, before emitting SDK event */) .then(emitSplitsArrivedEvent => { + const metadata: SdkUpdateMetadata = { + type: updatedFlags.length > 0 ? SdkUpdateMetadataKeys.FLAGS_UPDATE : SdkUpdateMetadataKeys.SEGMENTS_UPDATE, + names: updatedFlags.length > 0 ? updatedFlags : [] + }; // emit SDK events - if (emitSplitsArrivedEvent) splitsEventEmitter.emit(SDK_SPLITS_ARRIVED); + if (emitSplitsArrivedEvent) splitsEventEmitter.emit(SDK_SPLITS_ARRIVED, metadata); return true; }); } diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 1a505686..0d96bf21 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -1,8 +1,8 @@ // Type definitions for Split Software SDKs // Project: https://www.split.io/ -import { RedisOptions } from 'ioredis'; -import { RequestOptions } from 'http'; +import type { RedisOptions } from 'ioredis'; +import type { RequestOptions } from 'http'; export as namespace SplitIO; export = SplitIO; @@ -492,6 +492,38 @@ declare namespace SplitIO { removeItem(key: string): void | Promise; } + /** + * Metadata for the update event emitted when the SDK cache is updated with new data for flags or segments. + */ + type SdkUpdateMetadata = { + /** + * The type of update event. + */ + type: 'FLAGS_UPDATE' | 'SEGMENTS_UPDATE' + /** + * The names of the flags or segments that were updated. + */ + names: string[] + } + + /** + * Metadata for the ready events emitted when the SDK is ready to evaluate feature flags. + */ + type SdkReadyMetadata = { + /** + * Indicates whether the SDK was loaded from cache initially. + * - `true` when SDK_READY_FROM_CACHE is emitted from cache (before SDK_READY) + * - `true` when SDK_READY is emitted and the SDK was ready from cache first + * - `false` when SDK_READY_FROM_CACHE is emitted because SDK became ready without cache + * - `false` when SDK_READY is emitted and the SDK was not ready from cache + */ + initialCacheLoad: boolean + /** + * Timestamp in milliseconds since epoch when the event was emitted. + */ + lastUpdateTimestamp: number + } + /** * EventEmitter interface based on a subset of the Node.js EventEmitter methods. */ @@ -509,8 +541,17 @@ declare namespace SplitIO { * @see {@link https://nodejs.org/api/events.html} */ interface EventEmitter extends IEventEmitter { + addListener(event: EventConsts['SDK_READY'], listener: (metadata: SdkReadyMetadata) => void): this; + addListener(event: EventConsts['SDK_READY_FROM_CACHE'], listener: (metadata: SdkReadyMetadata) => void): this; + addListener(event: EventConsts['SDK_UPDATE'], listener: (metadata: SdkUpdateMetadata) => void): this; addListener(event: string | symbol, listener: (...args: any[]) => void): this; + on(event: EventConsts['SDK_READY'], listener: (metadata: SdkReadyMetadata) => void): this; + on(event: EventConsts['SDK_READY_FROM_CACHE'], listener: (metadata: SdkReadyMetadata) => void): this; + on(event: EventConsts['SDK_UPDATE'], listener: (metadata: SdkUpdateMetadata) => void): this; on(event: string | symbol, listener: (...args: any[]) => void): this; + once(event: EventConsts['SDK_READY'], listener: (metadata: SdkReadyMetadata) => void): this; + once(event: EventConsts['SDK_READY_FROM_CACHE'], listener: (metadata: SdkReadyMetadata) => void): this; + once(event: EventConsts['SDK_UPDATE'], listener: (metadata: SdkUpdateMetadata) => void): this; once(event: string | symbol, listener: (...args: any[]) => void): this; removeListener(event: string | symbol, listener: (...args: any[]) => void): this; off(event: string | symbol, listener: (...args: any[]) => void): this;