Skip to content

Commit 9b4542a

Browse files
authored
feat: allow clients to evaluate bootstrapped flags when not ready (#1036)
**Related issues** sdk-1653 sdk-1376 sdk-1663 sdk-1681 **Describe the solution you've provided** - Introduced `ActiveContextTracker` to manage the current active context and its serialized state. - Updated `LDClientImpl` to utilize the new context tracker for identifying and evaluating flags. - Added logic in `BrowserClientImpl` to read flags from bootstrap data during the initial identification process. - Added a new `presetFlags` function in client sdk common that allow flagstore to take in contexless data before initialization - Bootstrapped data will first `preset` the flag store so they can be evaluated before a full context is made - Added logic to suppress event creation if context is not validated (while the client only has preset data) **Additional context** - eventually I would like to consolidate the context tracker with the tracking logic we have for waitForInitialization and have a general state tracker for client sdk common. - I am also open to suggestions on distinguishing between "no context" state and "pre initialized" state. Though, it seems like, at the moment, those 2 states are handled in the same way. Supersedes #1024 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Enable flag evaluation from bootstrap data before identify completes and refactor context handling via ActiveContextTracker with event suppression when no valid context. > > - **Browser SDK**: > - Read bootstrap data in `BrowserClient.identifyResult` and `preset` flags for immediate evaluation; track first-identify via `_identifyAttempted`. > - Add test verifying flags evaluate from bootstrap before identify completes in `packages/sdk/browser/__tests__/BrowserClient.test.ts`. > - **Core SDK (shared)**: > - Introduce `ActiveContextTracker` (`src/context/createActiveContextTracker.ts`) and update `LDClientImpl` to use it for getting/setting context and coordinating identify promises. > - Allow evaluations without an active context (log warning) and suppress event creation when context is absent; add `presetFlags` to `LDClientImpl`. > - **Flag management**: > - Add `presetFlags` to `FlagManager`/`DefaultFlagManager` to initialize in-memory flags without persistence. > - Update `FlagUpdater` to track active `Context` (not just key) and adjust `init/initCached/upsert` and change callbacks accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b7b4bf8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b85e07b commit 9b4542a

File tree

7 files changed

+250
-66
lines changed

7 files changed

+250
-66
lines changed

packages/sdk/browser/__tests__/BrowserClient.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,43 @@ describe('given a mock platform for a BrowserClient', () => {
189189
});
190190
});
191191

192+
it('can evaluate flags with bootstrap data before identify completes', async () => {
193+
const client = makeClient(
194+
'client-side-id',
195+
AutoEnvAttributes.Disabled,
196+
{
197+
streaming: false,
198+
logger,
199+
diagnosticOptOut: true,
200+
},
201+
platform,
202+
);
203+
204+
const identifyPromise = client.identify(
205+
{ kind: 'user', key: 'bob' },
206+
{
207+
bootstrap: goodBootstrapDataWithReasons,
208+
},
209+
);
210+
211+
const flagValue = client.jsonVariationDetail('json', undefined);
212+
expect(flagValue).toEqual({
213+
reason: {
214+
kind: 'OFF',
215+
},
216+
value: ['a', 'b', 'c', 'd'],
217+
variationIndex: 1,
218+
});
219+
220+
expect(client.getContext()).toBeUndefined();
221+
222+
// Wait for identify to complete
223+
await identifyPromise;
224+
225+
// Verify that active context is now set
226+
expect(client.getContext()).toEqual({ kind: 'user', key: 'bob' });
227+
});
228+
192229
it('can shed intermediate identify calls', async () => {
193230
const client = makeClient(
194231
'client-side-id',

packages/sdk/browser/src/BrowserClient.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
Platform,
2020
} from '@launchdarkly/js-client-sdk-common';
2121

22+
import { readFlagsFromBootstrap } from './bootstrap';
2223
import { getHref } from './BrowserApi';
2324
import BrowserDataManager from './BrowserDataManager';
2425
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
@@ -44,6 +45,10 @@ class BrowserClientImpl extends LDClientImpl {
4445
private _initResolve?: (result: LDWaitForInitializationResult) => void;
4546
private _initializeResult?: LDWaitForInitializationResult;
4647

48+
// NOTE: keeps track of when we tried an initial identification. We should consolidate this
49+
// with the waitForInitialization logic in the future.
50+
private _identifyAttempted: boolean = false;
51+
4752
constructor(
4853
clientSideId: string,
4954
autoEnvAttributes: AutoEnvAttributes,
@@ -222,6 +227,22 @@ class BrowserClientImpl extends LDClientImpl {
222227
if (identifyOptions?.sheddable === undefined) {
223228
identifyOptionsWithUpdatedDefaults.sheddable = true;
224229
}
230+
231+
if (!this._identifyAttempted) {
232+
this._identifyAttempted = true;
233+
if (identifyOptionsWithUpdatedDefaults.bootstrap) {
234+
try {
235+
const bootstrapData = readFlagsFromBootstrap(
236+
this.logger,
237+
identifyOptionsWithUpdatedDefaults.bootstrap,
238+
);
239+
this.presetFlags(bootstrapData);
240+
} catch (error) {
241+
this.logger.error('Failed to bootstrap data', error);
242+
}
243+
}
244+
}
245+
225246
const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults);
226247
if (res.status === 'completed') {
227248
this._initializeResult = { status: 'complete' };

packages/shared/sdk-client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"build": "npx tsc --noEmit && rollup -c rollup.config.js && npm run make-package-jsons",
3838
"clean": "rimraf dist",
3939
"lint": "npx eslint . --ext .ts",
40-
"lint:fix": "yarn run lint -- --fix",
40+
"lint:fix": "npx eslint . --ext .ts --fix",
4141
"prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'",
4242
"check": "yarn && yarn prettier && yarn lint && tsc && yarn test"
4343
},

packages/shared/sdk-client/src/LDClientImpl.ts

Lines changed: 85 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ import { LDIdentifyOptions } from './api/LDIdentifyOptions';
3232
import { createAsyncTaskQueue } from './async/AsyncTaskQueue';
3333
import { Configuration, ConfigurationImpl, LDClientInternalOptions } from './configuration';
3434
import { addAutoEnv } from './context/addAutoEnv';
35+
import {
36+
ActiveContextTracker,
37+
createActiveContextTracker,
38+
} from './context/createActiveContextTracker';
3539
import { ensureKey } from './context/ensureKey';
3640
import { DataManager, DataManagerFactory } from './DataManager';
3741
import createDiagnosticsManager from './diagnostics/createDiagnosticsManager';
@@ -43,6 +47,7 @@ import createEventProcessor from './events/createEventProcessor';
4347
import EventFactory from './events/EventFactory';
4448
import DefaultFlagManager, { FlagManager } from './flag-manager/FlagManager';
4549
import { FlagChangeType } from './flag-manager/FlagUpdater';
50+
import { ItemDescriptor } from './flag-manager/ItemDescriptor';
4651
import HookRunner from './HookRunner';
4752
import { getInspectorHook } from './inspection/getInspectorHook';
4853
import InspectorManager from './inspection/InspectorManager';
@@ -55,12 +60,12 @@ const DEFAULT_IDENTIFY_TIMEOUT_SECONDS = 5;
5560

5661
export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
5762
private readonly _config: Configuration;
58-
private _uncheckedContext?: LDContext;
59-
private _checkedContext?: Context;
6063
private readonly _diagnosticsManager?: internal.DiagnosticsManager;
6164
private _eventProcessor?: internal.EventProcessor;
6265
readonly logger: LDLogger;
6366

67+
private _activeContextTracker: ActiveContextTracker = createActiveContextTracker();
68+
6469
private readonly _highTimeoutThreshold: number = 15;
6570

6671
private _eventFactoryDefault = new EventFactory(false);
@@ -200,27 +205,22 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
200205
// code. We are returned the unchecked context so that if a consumer identifies with an invalid context
201206
// and then calls getContext, they get back the same context they provided, without any assertion about
202207
// validity.
203-
return this._uncheckedContext ? clone<LDContext>(this._uncheckedContext) : undefined;
208+
return this._activeContextTracker.hasContext()
209+
? clone<LDContext>(this._activeContextTracker.getUnwrappedContext())
210+
: undefined;
204211
}
205212

206213
protected getInternalContext(): Context | undefined {
207-
return this._checkedContext;
214+
return this._activeContextTracker.getContext();
208215
}
209216

210-
private _createIdentifyPromise(): {
211-
identifyPromise: Promise<void>;
212-
identifyResolve: () => void;
213-
identifyReject: (err: Error) => void;
214-
} {
215-
let res: any;
216-
let rej: any;
217-
218-
const basePromise = new Promise<void>((resolve, reject) => {
219-
res = resolve;
220-
rej = reject;
221-
});
222-
223-
return { identifyPromise: basePromise, identifyResolve: res, identifyReject: rej };
217+
/**
218+
* Preset flags are used to set the flags before the client is initialized. This is useful for
219+
* when client has precached flags that are ready to evaluate without full initialization.
220+
* @param newFlags - The flags to preset.
221+
*/
222+
protected presetFlags(newFlags: { [key: string]: ItemDescriptor }) {
223+
this._flagManager.presetFlags(newFlags);
224224
}
225225

226226
/**
@@ -307,15 +307,14 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
307307
this.emitter.emit('error', context, error);
308308
return Promise.reject(error);
309309
}
310-
this._uncheckedContext = context;
311-
this._checkedContext = checkedContext;
310+
this._activeContextTracker.set(context, checkedContext);
312311

313312
this._eventProcessor?.sendEvent(
314-
this._eventFactoryDefault.identifyEvent(this._checkedContext),
313+
this._eventFactoryDefault.identifyEvent(checkedContext),
315314
);
316315
const { identifyPromise, identifyResolve, identifyReject } =
317-
this._createIdentifyPromise();
318-
this.logger.debug(`Identifying ${JSON.stringify(this._checkedContext)}`);
316+
this._activeContextTracker.newIdentificationPromise();
317+
this.logger.debug(`Identifying ${JSON.stringify(checkedContext)}`);
319318

320319
await this.dataManager.identify(
321320
identifyResolve,
@@ -370,7 +369,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
370369
}
371370

372371
track(key: string, data?: any, metricValue?: number): void {
373-
if (!this._checkedContext || !this._checkedContext.valid) {
372+
if (!this._activeContextTracker.hasValidContext()) {
374373
this.logger.warn(ClientMessages.MissingContextKeyNoEvent);
375374
return;
376375
}
@@ -382,14 +381,19 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
382381

383382
this._eventProcessor?.sendEvent(
384383
this._config.trackEventModifier(
385-
this._eventFactoryDefault.customEvent(key, this._checkedContext!, data, metricValue),
384+
this._eventFactoryDefault.customEvent(
385+
key,
386+
this._activeContextTracker.getContext()!,
387+
data,
388+
metricValue,
389+
),
386390
),
387391
);
388392

389393
this._hookRunner.afterTrack({
390394
key,
391395
// The context is pre-checked above, so we know it can be unwrapped.
392-
context: this._uncheckedContext!,
396+
context: this._activeContextTracker.getUnwrappedContext()!,
393397
data,
394398
metricValue,
395399
});
@@ -401,23 +405,34 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
401405
eventFactory: EventFactory,
402406
typeChecker?: (value: any) => [boolean, string],
403407
): LDEvaluationDetail {
404-
if (!this._uncheckedContext) {
405-
this.logger.debug(ClientMessages.MissingContextKeyNoEvent);
406-
return createErrorEvaluationDetail(ErrorKinds.UserNotSpecified, defaultValue);
408+
// We are letting evaulations happen without a context. The main case for this
409+
// is when cached data is loaded, but the client is not fully initialized. In this
410+
// case, we will write out a warning for each evaluation attempt.
411+
412+
// NOTE: we will be changing this behavior soon once we have a tracker on the
413+
// client initialization state.
414+
const hasContext = this._activeContextTracker.hasContext();
415+
if (!hasContext) {
416+
this.logger?.warn(
417+
'Flag evaluation called before client is fully initialized, data from this evaulation could be stale.',
418+
);
407419
}
408420

409-
const evalContext = Context.fromLDContext(this._uncheckedContext);
421+
const evalContext = this._activeContextTracker.getContext()!;
410422
const foundItem = this._flagManager.get(flagKey);
411423

412424
if (foundItem === undefined || foundItem.flag.deleted) {
413425
const defVal = defaultValue ?? null;
414426
const error = new LDClientError(
415427
`Unknown feature flag "${flagKey}"; returning default value ${defVal}.`,
416428
);
417-
this.emitter.emit('error', this._uncheckedContext, error);
418-
this._eventProcessor?.sendEvent(
419-
this._eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext),
420-
);
429+
430+
this.emitter.emit('error', this._activeContextTracker.getUnwrappedContext(), error);
431+
if (hasContext) {
432+
this._eventProcessor?.sendEvent(
433+
this._eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext),
434+
);
435+
}
421436
return createErrorEvaluationDetail(ErrorKinds.FlagNotFound, defaultValue);
422437
}
423438

@@ -426,20 +441,22 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
426441
if (typeChecker) {
427442
const [matched, type] = typeChecker(value);
428443
if (!matched) {
429-
this._eventProcessor?.sendEvent(
430-
eventFactory.evalEventClient(
431-
flagKey,
432-
defaultValue, // track default value on type errors
433-
defaultValue,
434-
foundItem.flag,
435-
evalContext,
436-
reason,
437-
),
438-
);
444+
if (hasContext) {
445+
this._eventProcessor?.sendEvent(
446+
eventFactory.evalEventClient(
447+
flagKey,
448+
defaultValue, // track default value on type errors
449+
defaultValue,
450+
foundItem.flag,
451+
evalContext,
452+
reason,
453+
),
454+
);
455+
}
439456
const error = new LDClientError(
440457
`Wrong type "${type}" for feature flag "${flagKey}"; returning default value`,
441458
);
442-
this.emitter.emit('error', this._uncheckedContext, error);
459+
this.emitter.emit('error', this._activeContextTracker.getUnwrappedContext(), error);
443460
return createErrorEvaluationDetail(ErrorKinds.WrongType, defaultValue);
444461
}
445462
}
@@ -453,31 +470,36 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
453470
prerequisites?.forEach((prereqKey) => {
454471
this._variationInternal(prereqKey, undefined, this._eventFactoryDefault);
455472
});
456-
this._eventProcessor?.sendEvent(
457-
eventFactory.evalEventClient(
458-
flagKey,
459-
value,
460-
defaultValue,
461-
foundItem.flag,
462-
evalContext,
463-
reason,
464-
),
465-
);
473+
if (hasContext) {
474+
this._eventProcessor?.sendEvent(
475+
eventFactory.evalEventClient(
476+
flagKey,
477+
value,
478+
defaultValue,
479+
foundItem.flag,
480+
evalContext,
481+
reason,
482+
),
483+
);
484+
}
466485
return successDetail;
467486
}
468487

469488
variation(flagKey: string, defaultValue?: LDFlagValue): LDFlagValue {
470489
const { value } = this._hookRunner.withEvaluation(
471490
flagKey,
472-
this._uncheckedContext,
491+
this._activeContextTracker.getUnwrappedContext(),
473492
defaultValue,
474493
() => this._variationInternal(flagKey, defaultValue, this._eventFactoryDefault),
475494
);
476495
return value;
477496
}
478497
variationDetail(flagKey: string, defaultValue?: LDFlagValue): LDEvaluationDetail {
479-
return this._hookRunner.withEvaluation(flagKey, this._uncheckedContext, defaultValue, () =>
480-
this._variationInternal(flagKey, defaultValue, this._eventFactoryWithReasons),
498+
return this._hookRunner.withEvaluation(
499+
flagKey,
500+
this._activeContextTracker.getUnwrappedContext(),
501+
defaultValue,
502+
() => this._variationInternal(flagKey, defaultValue, this._eventFactoryWithReasons),
481503
);
482504
}
483505

@@ -487,8 +509,11 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
487509
eventFactory: EventFactory,
488510
typeChecker: (value: unknown) => [boolean, string],
489511
): LDEvaluationDetailTyped<T> {
490-
return this._hookRunner.withEvaluation(key, this._uncheckedContext, defaultValue, () =>
491-
this._variationInternal(key, defaultValue, eventFactory, typeChecker),
512+
return this._hookRunner.withEvaluation(
513+
key,
514+
this._activeContextTracker.getUnwrappedContext(),
515+
defaultValue,
516+
() => this._variationInternal(key, defaultValue, eventFactory, typeChecker),
492517
);
493518
}
494519

0 commit comments

Comments
 (0)