Skip to content

Commit 0b9ed16

Browse files
committed
feat: implement active context tracking and bootstrap flag handling
- 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. This change improves the SDK's ability to handle context and flag management, particularly during initialization.
1 parent 156532a commit 0b9ed16

File tree

6 files changed

+163
-45
lines changed

6 files changed

+163
-45
lines changed

packages/sdk/browser/src/BrowserClient.ts

Lines changed: 19 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,20 @@ class BrowserClientImpl extends LDClientImpl {
222227
if (identifyOptions?.sheddable === undefined) {
223228
identifyOptionsWithUpdatedDefaults.sheddable = true;
224229
}
230+
231+
if (!this._identifyAttempted && identifyOptionsWithUpdatedDefaults.bootstrap) {
232+
this._identifyAttempted = true;
233+
const bootstrapData = readFlagsFromBootstrap(
234+
this.logger,
235+
identifyOptionsWithUpdatedDefaults.bootstrap,
236+
);
237+
try {
238+
this.presetFlags(bootstrapData);
239+
} catch {
240+
this.logger.error('Failed to bootstrap data');
241+
}
242+
}
243+
225244
const res = await super.identifyResult(context, identifyOptionsWithUpdatedDefaults);
226245
if (res.status === 'completed') {
227246
this._initializeResult = { status: 'complete' };

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

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,21 @@ import { getInspectorHook } from './inspection/getInspectorHook';
4848
import InspectorManager from './inspection/InspectorManager';
4949
import LDEmitter, { EventName } from './LDEmitter';
5050
import { createPluginEnvironmentMetadata } from './plugins/createPluginEnvironmentMetadata';
51+
import { ActiveContextTracker, createActiveContextTracker } from './context/createActiveContextTracker';
52+
import { ItemDescriptor } from './flag-manager/ItemDescriptor';
5153

5254
const { ClientMessages, ErrorKinds } = internal;
5355

5456
const DEFAULT_IDENTIFY_TIMEOUT_SECONDS = 5;
5557

5658
export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
5759
private readonly _config: Configuration;
58-
private _uncheckedContext?: LDContext;
59-
private _checkedContext?: Context;
6060
private readonly _diagnosticsManager?: internal.DiagnosticsManager;
6161
private _eventProcessor?: internal.EventProcessor;
6262
readonly logger: LDLogger;
6363

64+
private _activeContextTracker: ActiveContextTracker = createActiveContextTracker()
65+
6466
private readonly _highTimeoutThreshold: number = 15;
6567

6668
private _eventFactoryDefault = new EventFactory(false);
@@ -200,27 +202,20 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
200202
// code. We are returned the unchecked context so that if a consumer identifies with an invalid context
201203
// and then calls getContext, they get back the same context they provided, without any assertion about
202204
// validity.
203-
return this._uncheckedContext ? clone<LDContext>(this._uncheckedContext) : undefined;
205+
return this._activeContextTracker.hasContext() ? clone<LDContext>(this._activeContextTracker.getPristineContext()) : undefined;
204206
}
205207

206208
protected getInternalContext(): Context | undefined {
207-
return this._checkedContext;
209+
return this._activeContextTracker.getContext();
208210
}
209211

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 };
212+
/**
213+
* Preset flags are used to set the flags before the client is initialized. This is useful for
214+
* when client has precached flags that are ready to evaluate without full initialization.
215+
* @param newFlags - The flags to preset.
216+
*/
217+
protected presetFlags(newFlags: { [key: string]: ItemDescriptor }) {
218+
this._flagManager.presetFlags(newFlags);
224219
}
225220

226221
/**
@@ -307,15 +302,14 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
307302
this.emitter.emit('error', context, error);
308303
return Promise.reject(error);
309304
}
310-
this._uncheckedContext = context;
311-
this._checkedContext = checkedContext;
305+
this._activeContextTracker.set(context, checkedContext)
312306

313307
this._eventProcessor?.sendEvent(
314-
this._eventFactoryDefault.identifyEvent(this._checkedContext),
308+
this._eventFactoryDefault.identifyEvent(checkedContext),
315309
);
316310
const { identifyPromise, identifyResolve, identifyReject } =
317-
this._createIdentifyPromise();
318-
this.logger.debug(`Identifying ${JSON.stringify(this._checkedContext)}`);
311+
this._activeContextTracker.newIdentificationPromise();
312+
this.logger.debug(`Identifying ${JSON.stringify(checkedContext)}`);
319313

320314
await this.dataManager.identify(
321315
identifyResolve,
@@ -370,7 +364,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
370364
}
371365

372366
track(key: string, data?: any, metricValue?: number): void {
373-
if (!this._checkedContext || !this._checkedContext.valid) {
367+
if (!this._activeContextTracker.hasValidContext()) {
374368
this.logger.warn(ClientMessages.MissingContextKeyNoEvent);
375369
return;
376370
}
@@ -382,14 +376,14 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
382376

383377
this._eventProcessor?.sendEvent(
384378
this._config.trackEventModifier(
385-
this._eventFactoryDefault.customEvent(key, this._checkedContext!, data, metricValue),
379+
this._eventFactoryDefault.customEvent(key, this._activeContextTracker.getContext()!, data, metricValue),
386380
),
387381
);
388382

389383
this._hookRunner.afterTrack({
390384
key,
391385
// The context is pre-checked above, so we know it can be unwrapped.
392-
context: this._uncheckedContext!,
386+
context: this._activeContextTracker.getPristineContext()!,
393387
data,
394388
metricValue,
395389
});
@@ -401,21 +395,28 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
401395
eventFactory: EventFactory,
402396
typeChecker?: (value: any) => [boolean, string],
403397
): LDEvaluationDetail {
404-
if (!this._uncheckedContext) {
405-
this.logger.debug(ClientMessages.MissingContextKeyNoEvent);
406-
return createErrorEvaluationDetail(ErrorKinds.UserNotSpecified, defaultValue);
398+
// We are letting evaulations happen without a context. The main case for this
399+
// is when cached data is loaded, but the client is not fully initialized. In this
400+
// case, we will write out a warning for each evaluation attempt.
401+
402+
// NOTE: we will be changing this behavior soon once we have a tracker on the
403+
// client initialization state.
404+
const hasContext = this._activeContextTracker.hasContext()
405+
if (!hasContext) {
406+
this.logger?.warn('Flag evaluation called before client is fully initialized, data from this evaulation could be stale.')
407407
}
408408

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

412412
if (foundItem === undefined || foundItem.flag.deleted) {
413413
const defVal = defaultValue ?? null;
414414
const error = new LDClientError(
415415
`Unknown feature flag "${flagKey}"; returning default value ${defVal}.`,
416416
);
417-
this.emitter.emit('error', this._uncheckedContext, error);
418-
this._eventProcessor?.sendEvent(
417+
418+
this.emitter.emit('error', this._activeContextTracker.getPristineContext(), error);
419+
hasContext && this._eventProcessor?.sendEvent(
419420
this._eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext),
420421
);
421422
return createErrorEvaluationDetail(ErrorKinds.FlagNotFound, defaultValue);
@@ -426,7 +427,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
426427
if (typeChecker) {
427428
const [matched, type] = typeChecker(value);
428429
if (!matched) {
429-
this._eventProcessor?.sendEvent(
430+
hasContext && this._eventProcessor?.sendEvent(
430431
eventFactory.evalEventClient(
431432
flagKey,
432433
defaultValue, // track default value on type errors
@@ -439,7 +440,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
439440
const error = new LDClientError(
440441
`Wrong type "${type}" for feature flag "${flagKey}"; returning default value`,
441442
);
442-
this.emitter.emit('error', this._uncheckedContext, error);
443+
this.emitter.emit('error', this._activeContextTracker.getPristineContext(), error);
443444
return createErrorEvaluationDetail(ErrorKinds.WrongType, defaultValue);
444445
}
445446
}
@@ -453,7 +454,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
453454
prerequisites?.forEach((prereqKey) => {
454455
this._variationInternal(prereqKey, undefined, this._eventFactoryDefault);
455456
});
456-
this._eventProcessor?.sendEvent(
457+
hasContext && this._eventProcessor?.sendEvent(
457458
eventFactory.evalEventClient(
458459
flagKey,
459460
value,
@@ -469,14 +470,14 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
469470
variation(flagKey: string, defaultValue?: LDFlagValue): LDFlagValue {
470471
const { value } = this._hookRunner.withEvaluation(
471472
flagKey,
472-
this._uncheckedContext,
473+
this._activeContextTracker.getPristineContext(),
473474
defaultValue,
474475
() => this._variationInternal(flagKey, defaultValue, this._eventFactoryDefault),
475476
);
476477
return value;
477478
}
478479
variationDetail(flagKey: string, defaultValue?: LDFlagValue): LDEvaluationDetail {
479-
return this._hookRunner.withEvaluation(flagKey, this._uncheckedContext, defaultValue, () =>
480+
return this._hookRunner.withEvaluation(flagKey, this._activeContextTracker.getPristineContext(), defaultValue, () =>
480481
this._variationInternal(flagKey, defaultValue, this._eventFactoryWithReasons),
481482
);
482483
}
@@ -487,7 +488,7 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
487488
eventFactory: EventFactory,
488489
typeChecker: (value: unknown) => [boolean, string],
489490
): LDEvaluationDetailTyped<T> {
490-
return this._hookRunner.withEvaluation(key, this._uncheckedContext, defaultValue, () =>
491+
return this._hookRunner.withEvaluation(key, this._activeContextTracker.getPristineContext(), defaultValue, () =>
491492
this._variationInternal(key, defaultValue, eventFactory, typeChecker),
492493
);
493494
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Context, LDContext } from "@launchdarkly/js-sdk-common"
2+
3+
/**
4+
* ActiveContextTracker is an internal class that helps tracks the current active context
5+
* used by the client.
6+
*/
7+
export interface ActiveContextTracker {
8+
_pristineContext?: LDContext
9+
_context?: Context
10+
11+
/**
12+
* Set the active context and pristine context. This will only be called when the passed in context
13+
* is checked and valid.
14+
*
15+
* @param pristineContext - The pristine context, which is the context as it was passed in to the SDK.
16+
* @param context - The active context, which is the context as it was checked and validated.
17+
*/
18+
set(pristineContext: LDContext, context: Context): void
19+
20+
/**
21+
* Get the active context.
22+
*
23+
* @returns The active context or undefined if it has not been set.
24+
*/
25+
getContext(): Context | undefined
26+
27+
/**
28+
* Get the pristine context.
29+
*
30+
* @returns The pristine context or undefined if it has not been set.
31+
*/
32+
getPristineContext(): LDContext | undefined
33+
34+
/**
35+
* Create a new identification promise. To allow other parts of the SDK to track the identification process.
36+
*
37+
* TODO(self): this is a very generic method so maybe it doesn't belong here?
38+
*/
39+
newIdentificationPromise(): {
40+
identifyPromise: Promise<void>;
41+
identifyResolve: () => void;
42+
identifyReject: (err: Error) => void;
43+
}
44+
45+
/**
46+
* Check if the active context is set. Regardless of whether it is valid or not.
47+
*
48+
* @returns True if the active context is set, false otherwise.
49+
*/
50+
hasContext(): boolean
51+
52+
/**
53+
* Check if the active context is valid.
54+
*
55+
* @returns True if the active context is valid, false otherwise.
56+
*/
57+
hasValidContext(): boolean
58+
}
59+
60+
export function createActiveContextTracker(): ActiveContextTracker {
61+
return {
62+
_pristineContext: undefined,
63+
_context: undefined,
64+
set(pristineContext: LDContext, context: Context) {
65+
this._pristineContext = pristineContext;
66+
this._context = context;
67+
},
68+
getContext() { return this._context; },
69+
getPristineContext() { return this._pristineContext; },
70+
newIdentificationPromise() {
71+
let res: () => void;
72+
let rej: (err: Error) => void;
73+
74+
const basePromise = new Promise<void>((resolve, reject) => {
75+
res = resolve;
76+
rej = reject;
77+
});
78+
79+
return { identifyPromise: basePromise, identifyResolve: res!, identifyReject: rej! };
80+
},
81+
hasContext() { return this._context !== undefined; },
82+
hasValidContext() { return this.hasContext() && this._context!.valid; },
83+
};
84+
}

packages/shared/sdk-client/src/context/ensureKey.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from '@launchdarkly/js-sdk-common';
1111

1212
import { getOrGenerateKey } from '../storage/getOrGenerateKey';
13-
import { namespaceForAnonymousGeneratedContextKey } from '../storage/namespaceUtils';
13+
import { namespaceForGeneratedContextKey } from '../storage/namespaceUtils';
1414

1515
const { isLegacyUser, isMultiKind, isSingleKind } = internal;
1616

@@ -31,7 +31,7 @@ const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platf
3131
const { anonymous, key } = c;
3232

3333
if (anonymous && !key) {
34-
const storageKey = await namespaceForAnonymousGeneratedContextKey(kind);
34+
const storageKey = await namespaceForGeneratedContextKey(kind);
3535
// This mutates a cloned copy of the original context from ensureyKey so this is safe.
3636
// eslint-disable-next-line no-param-reassign
3737
c.key = await getOrGenerateKey(storageKey, platform);

packages/shared/sdk-client/src/flag-manager/FlagManager.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ export interface FlagManager {
4040
*/
4141
loadCached(context: Context): Promise<boolean>;
4242

43+
44+
/**
45+
* Updates in-memory storage with the specified flags without a context
46+
* or persistent storage. Flags set in this way are considered emphemeral and
47+
* should be replaced as soon as initialization is done.
48+
*
49+
* @param newFlags - cached flags
50+
*/
51+
presetFlags(newFlags: { [key: string]: ItemDescriptor }): void;
52+
4353
/**
4454
* Update in-memory storage with the specified flags, but do not persistent them to cache
4555
* storage.
@@ -114,6 +124,10 @@ export default class DefaultFlagManager implements FlagManager {
114124
return this._flagStore.getAll();
115125
}
116126

127+
presetFlags(newFlags: { [key: string]: ItemDescriptor }): void {
128+
this._flagStore.init(newFlags);
129+
}
130+
117131
setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void {
118132
// Bypasses the persistence as we do not want to put these flags into any cache.
119133
// Generally speaking persistence likely *SHOULD* be disabled when using bootstrap.

0 commit comments

Comments
 (0)