Skip to content

Commit 93a3478

Browse files
[8.19] [Feature Flags] Client-side bootstrapping (#224258) (#224833)
# Backport This will backport the following commits from `main` to `8.19`: - [[Feature Flags] Client-side bootstrapping (#224258)](#224258) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Alejandro Fernández Haro","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-06-23T10:28:06Z","message":"[Feature Flags] Client-side bootstrapping (#224258)","sha":"fd7f85149cecdcd7f73d1b914f2c7b83fbd01781","branchLabelMapping":{"^v9.1.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Core","Team:Security","enhancement","release_note:skip","backport:version","v9.1.0","v8.19.0"],"title":"[Feature Flags] Client-side bootstrapping","number":224258,"url":"https://github.com/elastic/kibana/pull/224258","mergeCommit":{"message":"[Feature Flags] Client-side bootstrapping (#224258)","sha":"fd7f85149cecdcd7f73d1b914f2c7b83fbd01781"}},"sourceBranch":"main","suggestedTargetBranches":["8.19"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/224258","number":224258,"mergeCommit":{"message":"[Feature Flags] Client-side bootstrapping (#224258)","sha":"fd7f85149cecdcd7f73d1b914f2c7b83fbd01781"}},{"branch":"8.19","label":"v8.19.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> --------- Co-authored-by: Alejandro Fernández Haro <[email protected]>
1 parent 10c0a56 commit 93a3478

File tree

24 files changed

+346
-18
lines changed

24 files changed

+346
-18
lines changed

src/core/packages/feature-flags/browser-internal/src/feature_flags_service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ describe('FeatureFlagsService Browser', () => {
249249
'myPlugin.myOverriddenFlag': true,
250250
myDestructuredObjPlugin: { myOverriddenFlag: true },
251251
},
252+
initialFeatureFlags: {},
252253
});
253254
featureFlagsService.setup({ injectedMetadata });
254255
startContract = await featureFlagsService.start();

src/core/packages/feature-flags/browser-internal/src/feature_flags_service.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { get } from 'lodash';
2424

2525
/**
2626
* setup method dependencies
27-
* @private
27+
* @internal
2828
*/
2929
export interface FeatureFlagsSetupDeps {
3030
/**
@@ -35,7 +35,7 @@ export interface FeatureFlagsSetupDeps {
3535

3636
/**
3737
* The browser-side Feature Flags Service
38-
* @private
38+
* @internal
3939
*/
4040
export class FeatureFlagsService {
4141
private readonly featureFlagsClient: Client;
@@ -64,6 +64,7 @@ export class FeatureFlagsService {
6464
this.overrides = featureFlagsInjectedMetadata.overrides;
6565
}
6666
return {
67+
getInitialFeatureFlags: () => featureFlagsInjectedMetadata?.initialFeatureFlags ?? {},
6768
setProvider: (provider) => {
6869
if (this.isProviderReadyPromise) {
6970
throw new Error('A provider has already been set. This API cannot be called twice.');
@@ -159,7 +160,7 @@ export class FeatureFlagsService {
159160

160161
/**
161162
* Waits for the provider initialization with a timeout to avoid holding the page load for too long
162-
* @private
163+
* @internal
163164
*/
164165
private async waitForProviderInitialization() {
165166
// Adding a timeout here to avoid hanging the start for too long if the provider is unresponsive
@@ -185,7 +186,7 @@ export class FeatureFlagsService {
185186
* @param evaluationFn The actual evaluation API
186187
* @param flagName The name of the flag to evaluate
187188
* @param fallbackValue The fallback value
188-
* @private
189+
* @internal
189190
*/
190191
private evaluateFlag<T extends string | boolean | number>(
191192
evaluationFn: (flagName: string, fallbackValue: T) => T,
@@ -206,7 +207,7 @@ export class FeatureFlagsService {
206207
/**
207208
* Formats the provided context to fulfill the expected multi-context structure.
208209
* @param contextToAppend The {@link EvaluationContext} to append.
209-
* @private
210+
* @internal
210211
*/
211212
private async appendContext(contextToAppend: EvaluationContext): Promise<void> {
212213
// If no kind provided, default to the project|deployment level.

src/core/packages/feature-flags/browser-mocks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { of } from 'rxjs';
1414

1515
const createFeatureFlagsSetup = (): jest.Mocked<FeatureFlagsSetup> => {
1616
return {
17+
getInitialFeatureFlags: jest.fn().mockImplementation(() => ({})),
1718
setProvider: jest.fn(),
1819
appendContext: jest.fn().mockImplementation(Promise.resolve),
1920
};

src/core/packages/feature-flags/browser/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & {
8787
* @public
8888
*/
8989
export interface FeatureFlagsSetup {
90+
/**
91+
* Used for bootstrapping the browser-side client with a seed of the feature flags for faster load-times.
92+
* @remarks It shouldn't be used to evaluate the feature flags because it won't report usage.
93+
*/
94+
getInitialFeatureFlags: () => Record<string, unknown>;
95+
9096
/**
9197
* Registers an OpenFeature provider to talk to the
9298
* 3rd-party service that manages the Feature Flags.

src/core/packages/feature-flags/server-internal/src/feature_flags_config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,23 @@ import { schema } from '@kbn/config-schema';
1212

1313
/**
1414
* The definition of the validation config schema
15-
* @private
15+
* @internal
1616
*/
1717
const configSchema = schema.object({
1818
overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())),
1919
});
2020

2121
/**
2222
* Type definition of the Feature Flags configuration
23-
* @private
23+
* @internal
2424
*/
2525
export interface FeatureFlagsConfig {
2626
overrides?: Record<string, unknown>;
2727
}
2828

2929
/**
3030
* Config descriptor for the feature flags service
31-
* @private
31+
* @internal
3232
*/
3333
export const featureFlagsConfig: ServiceConfigDescriptor<FeatureFlagsConfig> = {
3434
/**

src/core/packages/feature-flags/server-internal/src/feature_flags_service.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,4 +320,19 @@ describe('FeatureFlagsService Server', () => {
320320
'myDestructuredObjPlugin.myOverriddenFlag': true,
321321
});
322322
});
323+
324+
describe('bootstrapping helpers', () => {
325+
test('return empty initial feature flags if no getter registered', async () => {
326+
const { getInitialFeatureFlags } = featureFlagsService.setup();
327+
await expect(getInitialFeatureFlags()).resolves.toEqual({});
328+
});
329+
330+
test('calls the getter when registered', async () => {
331+
const { setInitialFeatureFlagsGetter, getInitialFeatureFlags } = featureFlagsService.setup();
332+
const mockGetter = jest.fn().mockResolvedValue({ myFlag: true });
333+
setInitialFeatureFlagsGetter(mockGetter);
334+
await expect(getInitialFeatureFlags()).resolves.toEqual({ myFlag: true });
335+
expect(mockGetter).toHaveBeenCalledTimes(1);
336+
});
337+
});
323338
});

src/core/packages/feature-flags/server-internal/src/feature_flags_service.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,31 +26,38 @@ import {
2626
import deepMerge from 'deepmerge';
2727
import { filter, switchMap, startWith, Subject, BehaviorSubject, pairwise, takeUntil } from 'rxjs';
2828
import { get } from 'lodash';
29+
import type { InitialFeatureFlagsGetter } from '@kbn/core-feature-flags-server/src/contracts';
2930
import { createOpenFeatureLogger } from './create_open_feature_logger';
3031
import { setProviderWithRetries } from './set_provider_with_retries';
3132
import { type FeatureFlagsConfig, featureFlagsConfig } from './feature_flags_config';
3233

3334
/**
3435
* Core-internal contract for the setup lifecycle step.
35-
* @private
36+
* @internal
3637
*/
3738
export interface InternalFeatureFlagsSetup extends FeatureFlagsSetup {
3839
/**
3940
* Used by the rendering service to share the overrides with the service on the browser side.
4041
*/
4142
getOverrides: () => Record<string, unknown>;
43+
/**
44+
* Required to bootstrap the browser-side OpenFeature client with a seed of the feature flags for faster load-times
45+
* and to work-around air-gapped environments.
46+
*/
47+
getInitialFeatureFlags: () => Promise<Record<string, unknown>>;
4248
}
4349

4450
/**
4551
* The server-side Feature Flags Service
46-
* @private
52+
* @internal
4753
*/
4854
export class FeatureFlagsService {
4955
private readonly featureFlagsClient: Client;
5056
private readonly logger: Logger;
5157
private readonly stop$ = new Subject<void>();
5258
private readonly overrides$ = new BehaviorSubject<Record<string, unknown>>({});
5359
private context: MultiContextEvaluationContext = { kind: 'multi' };
60+
private initialFeatureFlagsGetter: InitialFeatureFlagsGetter = async () => ({});
5461

5562
/**
5663
* The core service's constructor
@@ -77,6 +84,10 @@ export class FeatureFlagsService {
7784

7885
return {
7986
getOverrides: () => this.overrides$.value,
87+
getInitialFeatureFlags: () => this.initialFeatureFlagsGetter(),
88+
setInitialFeatureFlagsGetter: (getter: InitialFeatureFlagsGetter) => {
89+
this.initialFeatureFlagsGetter = getter;
90+
},
8091
setProvider: (provider) => {
8192
if (OpenFeature.providerMetadata !== NOOP_PROVIDER.metadata) {
8293
throw new Error('A provider has already been set. This API cannot be called twice.');
@@ -175,7 +186,7 @@ export class FeatureFlagsService {
175186
* @param evaluationFn The actual evaluation API
176187
* @param flagName The name of the flag to evaluate
177188
* @param fallbackValue The fallback value
178-
* @private
189+
* @internal
179190
*/
180191
private async evaluateFlag<T extends string | boolean | number>(
181192
evaluationFn: (flagName: string, fallbackValue: T) => Promise<T>,
@@ -196,7 +207,7 @@ export class FeatureFlagsService {
196207
/**
197208
* Formats the provided context to fulfill the expected multi-context structure.
198209
* @param contextToAppend The {@link EvaluationContext} to append.
199-
* @private
210+
* @internal
200211
*/
201212
private appendContext(contextToAppend: EvaluationContext): void {
202213
// If no kind provided, default to the project|deployment level.

src/core/packages/feature-flags/server-mocks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ const createFeatureFlagsInternalSetup = (): jest.Mocked<InternalFeatureFlagsSetu
2323
return {
2424
...createFeatureFlagsSetup(),
2525
getOverrides: jest.fn().mockReturnValue({}),
26+
getInitialFeatureFlags: jest.fn().mockImplementation(async () => ({})),
2627
};
2728
};
2829

2930
const createFeatureFlagsSetup = (): jest.Mocked<FeatureFlagsSetup> => {
3031
return {
32+
setInitialFeatureFlagsGetter: jest.fn(),
3133
setProvider: jest.fn(),
3234
appendContext: jest.fn(),
3335
};

src/core/packages/feature-flags/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type {
1313
SingleContextEvaluationContext,
1414
FeatureFlagsSetup,
1515
FeatureFlagsStart,
16+
InitialFeatureFlagsGetter,
1617
} from './src/contracts';
1718
export type { FeatureFlagDefinition, FeatureFlagDefinitions } from './src/feature_flag_definition';
1819
export type { FeatureFlagsRequestHandlerContext } from './src/request_handler_context';

src/core/packages/feature-flags/server/src/contracts.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & {
8282
kind?: 'organization' | 'kibana';
8383
};
8484

85+
/**
86+
* Getter function type to retrieve the initial feature flags.
87+
* @internal
88+
*/
89+
export type InitialFeatureFlagsGetter = () => Promise<Record<string, unknown>>;
90+
8591
/**
8692
* Setup contract of the Feature Flags Service
8793
* @public
@@ -101,6 +107,15 @@ export interface FeatureFlagsSetup {
101107
* @public
102108
*/
103109
appendContext(contextToAppend: EvaluationContext): void;
110+
111+
/**
112+
* Registers a getter function that will be used to retrieve the initial feature flags to be injected into the
113+
* browser for faster bootstrapping.
114+
* @param getter A function that returns all the feature flags and their values for this context.
115+
* Ideally, using the underlying API shouldn't track them as actual evaluations.
116+
* @internal
117+
*/
118+
setInitialFeatureFlagsGetter(getter: InitialFeatureFlagsGetter): void;
104119
}
105120

106121
/**

0 commit comments

Comments
 (0)