Skip to content

Commit 5464fbc

Browse files
sdk_ready metadata
1 parent fe6d284 commit 5464fbc

File tree

14 files changed

+106
-68
lines changed

14 files changed

+106
-68
lines changed

CHANGES.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
2.11.0 (January XX, 2026)
22
- 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.
3-
- 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).
3+
- Added metadata to SDK_READY and SDK_READY_FROM_CACHE events, including `initialCacheLoad` (boolean: `true` for fresh install/first app launch, `false` for warm cache/second app launch) and `lastUpdateTimestamp` (Int64 milliseconds since epoch).
44

55
2.10.1 (December 18, 2025)
66
- Bugfix - Handle `null` prerequisites properly.

src/readiness/__tests__/readinessManager.spec.ts

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,13 @@ test('READINESS MANAGER / Ready from cache event should be fired once', (done) =
100100
counter++;
101101
});
102102

103-
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
104-
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
103+
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
104+
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
105105
setTimeout(() => {
106-
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
106+
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
107107
}, 0);
108-
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
109-
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
110-
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
111-
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
108+
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
109+
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
112110

113111
setTimeout(() => {
114112
expect(counter).toBe(1); // should be called only once
@@ -366,19 +364,21 @@ test('READINESS MANAGER / SDK_UPDATE should forward metadata from segments', ()
366364
test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when cache is loaded', () => {
367365
const readinessManager = readinessManagerFactory(EventEmitter, settings);
368366

367+
const cacheTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
369368
let receivedMetadata: SdkReadyMetadata | undefined;
370369
readinessManager.gate.on(SDK_READY_FROM_CACHE, (meta: SdkReadyMetadata) => {
371370
receivedMetadata = meta;
372371
});
373372

374-
// Emit cache loaded event
375-
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
373+
// Emit cache loaded event with timestamp
374+
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, {
375+
isCacheValid: true,
376+
lastUpdateTimestamp: cacheTimestamp
377+
});
376378

377379
expect(receivedMetadata).toBeDefined();
378-
expect(receivedMetadata!.initialCacheLoad).toBe(true);
379-
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
380-
// Allow small timing difference (up to 10ms)
381-
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
380+
expect(receivedMetadata!.initialCacheLoad).toBe(false);
381+
expect(receivedMetadata!.lastUpdateTimestamp).toBe(cacheTimestamp);
382382
});
383383

384384
test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when SDK becomes ready without cache', () => {
@@ -394,17 +394,16 @@ test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when SD
394394
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);
395395

396396
expect(receivedMetadata).toBeDefined();
397-
expect(receivedMetadata!.initialCacheLoad).toBe(false);
398-
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
399-
// Allow small timing difference (up to 10ms)
400-
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
397+
expect(receivedMetadata!.initialCacheLoad).toBe(true);
398+
expect(receivedMetadata!.lastUpdateTimestamp).toBeNull();
401399
});
402400

403401
test('READINESS MANAGER / SDK_READY should emit with metadata when ready from cache', () => {
404402
const readinessManager = readinessManagerFactory(EventEmitter, settings);
405403

406-
// First emit cache loaded
407-
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
404+
const cacheTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
405+
// First emit cache loaded with timestamp
406+
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: cacheTimestamp });
408407

409408
let receivedMetadata: SdkReadyMetadata | undefined;
410409
readinessManager.gate.on(SDK_READY, (meta: SdkReadyMetadata) => {
@@ -416,10 +415,8 @@ test('READINESS MANAGER / SDK_READY should emit with metadata when ready from ca
416415
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);
417416

418417
expect(receivedMetadata).toBeDefined();
419-
expect(receivedMetadata!.initialCacheLoad).toBe(true); // Was ready from cache first
420-
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
421-
// Allow small timing difference (up to 10ms)
422-
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
418+
expect(receivedMetadata!.initialCacheLoad).toBe(false); // Was ready from cache first
419+
expect(receivedMetadata!.lastUpdateTimestamp).toBe(cacheTimestamp);
423420
});
424421

425422
test('READINESS MANAGER / SDK_READY should emit with metadata when ready without cache', () => {
@@ -435,8 +432,6 @@ test('READINESS MANAGER / SDK_READY should emit with metadata when ready without
435432
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);
436433

437434
expect(receivedMetadata).toBeDefined();
438-
expect(receivedMetadata!.initialCacheLoad).toBe(false); // Was not ready from cache
439-
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
440-
// Allow small timing difference (up to 10ms)
441-
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
435+
expect(receivedMetadata!.initialCacheLoad).toBe(true); // Was not ready from cache
436+
expect(receivedMetadata!.lastUpdateTimestamp).toBeNull(); // No cache timestamp when fresh install
442437
});

src/readiness/__tests__/sdkReadinessManager.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ describe('SDK Readiness Manager - Promises', () => {
219219
const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings);
220220

221221
// make the SDK ready from cache
222-
sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
222+
sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
223223
expect(await sdkReadinessManager.sdkStatus.whenReadyFromCache()).toBe(false);
224224

225225
// validate error log for SDK_READY_FROM_CACHE

src/readiness/readinessManager.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ISettings } from '../types';
33
import SplitIO from '../../types/splitio';
44
import { SDK_SPLITS_ARRIVED, SDK_SPLITS_CACHE_LOADED, SDK_SEGMENTS_ARRIVED, SDK_READY_TIMED_OUT, SDK_READY_FROM_CACHE, SDK_UPDATE, SDK_READY } from './constants';
55
import { IReadinessEventEmitter, IReadinessManager, ISegmentsEventEmitter, ISplitsEventEmitter } from './types';
6+
import { CacheValidationMetadata } from '../storages/inLocalStorage/validateCache';
67

78
function splitsEventEmitterFactory(EventEmitter: new () => SplitIO.IEventEmitter): ISplitsEventEmitter {
89
const splitsEventEmitter = objectAssign(new EventEmitter(), {
@@ -55,6 +56,7 @@ export function readinessManagerFactory(
5556

5657
// emit SDK_READY_FROM_CACHE
5758
let isReadyFromCache = false;
59+
let cacheLastUpdateTimestamp: number | null = null;
5860
if (splits.splitsCacheLoaded) isReadyFromCache = true; // ready from cache, but doesn't emit SDK_READY_FROM_CACHE
5961
else splits.once(SDK_SPLITS_CACHE_LOADED, checkIsReadyFromCache);
6062

@@ -84,17 +86,17 @@ export function readinessManagerFactory(
8486
splits.initCallbacks.push(__init);
8587
if (splits.hasInit) __init();
8688

87-
function checkIsReadyFromCache() {
89+
function checkIsReadyFromCache(cacheMetadata: CacheValidationMetadata) {
8890
isReadyFromCache = true;
91+
cacheLastUpdateTimestamp = cacheMetadata.lastUpdateTimestamp;
8992
// Don't emit SDK_READY_FROM_CACHE if SDK_READY has been emitted
9093
if (!isReady && !isDestroyed) {
9194
try {
9295
syncLastUpdate();
93-
const metadata: SplitIO.SdkReadyMetadata = {
94-
initialCacheLoad: true,
95-
lastUpdateTimestamp: lastUpdate
96-
};
97-
gate.emit(SDK_READY_FROM_CACHE, metadata);
96+
gate.emit(SDK_READY_FROM_CACHE, {
97+
initialCacheLoad: !cacheMetadata.isCacheValid,
98+
lastUpdateTimestamp: cacheLastUpdateTimestamp
99+
});
98100
} catch (e) {
99101
// throws user callback exceptions in next tick
100102
setTimeout(() => { throw e; }, 0);
@@ -117,19 +119,18 @@ export function readinessManagerFactory(
117119
clearTimeout(readyTimeoutId);
118120
isReady = true;
119121
try {
120-
syncLastUpdate();
121122
const wasReadyFromCache = isReadyFromCache;
122123
if (!isReadyFromCache) {
123124
isReadyFromCache = true;
124-
const metadataFromCache: SplitIO.SdkReadyMetadata = {
125-
initialCacheLoad: false,
126-
lastUpdateTimestamp: lastUpdate
125+
const metadataReadyFromCache: SplitIO.SdkReadyMetadata = {
126+
initialCacheLoad: true,
127+
lastUpdateTimestamp: null // No cache timestamp when fresh install
127128
};
128-
gate.emit(SDK_READY_FROM_CACHE, metadataFromCache);
129+
gate.emit(SDK_READY_FROM_CACHE, metadataReadyFromCache);
129130
}
130131
const metadataReady: SplitIO.SdkReadyMetadata = {
131-
initialCacheLoad: wasReadyFromCache,
132-
lastUpdateTimestamp: lastUpdate
132+
initialCacheLoad: !wasReadyFromCache,
133+
lastUpdateTimestamp: wasReadyFromCache ? cacheLastUpdateTimestamp : null
133134
};
134135
gate.emit(SDK_READY, metadataReady);
135136
} catch (e) {

src/sdkFactory/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,18 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA
5454
return;
5555
}
5656
readiness.splits.emit(SDK_SPLITS_ARRIVED);
57-
readiness.segments.emit(SDK_SEGMENTS_ARRIVED);
57+
readiness.segments.emit(SDK_SEGMENTS_ARRIVED, { isCacheValid: true, lastUpdateTimestamp: null });
5858
},
5959
onReadyFromCacheCb() {
60-
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
60+
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
6161
}
6262
});
6363

6464
const fallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings.fallbackTreatments);
6565

6666
if (initialRolloutPlan) {
6767
setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key));
68-
if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
68+
if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
6969
}
7070

7171
const clients: Record<string, SplitIO.IBasicClient> = {};

src/storages/inLocalStorage/__tests__/validateCache.spec.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ describe.each(storages)('validateCache', (storage) => {
3030
});
3131

3232
test('if there is no cache, it should return false', async () => {
33-
expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
33+
const result = await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
34+
expect(result.isCacheValid).toBe(false);
35+
expect(result.lastUpdateTimestamp).toBeNull();
3436

3537
expect(logSpy).not.toHaveBeenCalled();
3638

@@ -45,11 +47,15 @@ describe.each(storages)('validateCache', (storage) => {
4547
});
4648

4749
test('if there is cache and it must not be cleared, it should return true', async () => {
50+
const lastUpdateTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
4851
storage.setItem(keys.buildSplitsTillKey(), '1');
4952
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
53+
storage.setItem(keys.buildLastUpdatedKey(), lastUpdateTimestamp + '');
5054
await storage.save && storage.save();
5155

52-
expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
56+
const result = await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
57+
expect(result.isCacheValid).toBe(true);
58+
expect(result.lastUpdateTimestamp).toBe(lastUpdateTimestamp);
5359

5460
expect(logSpy).not.toHaveBeenCalled();
5561

@@ -69,7 +75,9 @@ describe.each(storages)('validateCache', (storage) => {
6975
storage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago
7076
await storage.save && storage.save();
7177

72-
expect(await validateCache({ expirationDays: 1 }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
78+
const result = await validateCache({ expirationDays: 1 }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
79+
expect(result.isCacheValid).toBe(false);
80+
expect(result.lastUpdateTimestamp).toBeNull();
7381

7482
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache');
7583

@@ -87,7 +95,9 @@ describe.each(storages)('validateCache', (storage) => {
8795
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
8896
await storage.save && storage.save();
8997

90-
expect(await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
98+
const result = await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments);
99+
expect(result.isCacheValid).toBe(false);
100+
expect(result.lastUpdateTimestamp).toBeNull();
91101

92102
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');
93103

@@ -107,7 +117,9 @@ describe.each(storages)('validateCache', (storage) => {
107117
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
108118
await storage.save && storage.save();
109119

110-
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
120+
const result = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
121+
expect(result.isCacheValid).toBe(false);
122+
expect(result.lastUpdateTimestamp).toBeNull();
111123

112124
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
113125

@@ -122,14 +134,20 @@ describe.each(storages)('validateCache', (storage) => {
122134

123135
// If cache is cleared, it should not clear again until a day has passed
124136
logSpy.mockClear();
137+
const lastUpdateTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
125138
storage.setItem(keys.buildSplitsTillKey(), '1');
126-
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
139+
storage.setItem(keys.buildLastUpdatedKey(), lastUpdateTimestamp + '');
140+
const result2 = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
141+
expect(result2.isCacheValid).toBe(true);
142+
expect(result2.lastUpdateTimestamp).toBe(lastUpdateTimestamp);
127143
expect(logSpy).not.toHaveBeenCalled();
128144
expect(storage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed
129145

130146
// If a day has passed, it should clear again
131147
storage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
132-
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
148+
const result3 = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
149+
expect(result3.isCacheValid).toBe(false);
150+
expect(result3.lastUpdateTimestamp).toBeNull();
133151
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
134152
expect(splits.clear).toHaveBeenCalledTimes(2);
135153
expect(rbSegments.clear).toHaveBeenCalledTimes(2);

src/storages/inLocalStorage/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { STORAGE_LOCALSTORAGE } from '../../utils/constants';
1414
import { shouldRecordTelemetry, TelemetryCacheInMemory } from '../inMemory/TelemetryCacheInMemory';
1515
import { UniqueKeysCacheInMemoryCS } from '../inMemory/UniqueKeysCacheInMemoryCS';
1616
import { getMatching } from '../../utils/key';
17-
import { validateCache } from './validateCache';
17+
import { validateCache, CacheValidationMetadata } from './validateCache';
1818
import { ILogger } from '../../logger/types';
1919
import SplitIO from '../../../types/splitio';
2020
import { storageAdapter } from './storageAdapter';
@@ -54,7 +54,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
5454
const rbSegments = new RBSegmentsCacheInLocal(settings, keys, storage);
5555
const segments = new MySegmentsCacheInLocal(log, keys, storage);
5656
const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey), storage);
57-
let validateCachePromise: Promise<boolean> | undefined;
57+
let validateCachePromise: Promise<CacheValidationMetadata> | undefined;
5858

5959
return {
6060
splits,
@@ -68,7 +68,10 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
6868
uniqueKeys: new UniqueKeysCacheInMemoryCS(),
6969

7070
validateCache() {
71-
return validateCachePromise || (validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments));
71+
if (!validateCachePromise) {
72+
validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments);
73+
}
74+
return validateCachePromise;
7275
},
7376

7477
save() {

0 commit comments

Comments
 (0)