[Feature Flags] Client-side bootstrapping (#224258)

This commit is contained in:
Alejandro Fernández Haro 2025-06-23 12:28:06 +02:00 committed by GitHub
parent 8d1204bb5f
commit fd7f85149c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 348 additions and 18 deletions

View file

@ -249,6 +249,7 @@ describe('FeatureFlagsService Browser', () => {
'myPlugin.myOverriddenFlag': true, 'myPlugin.myOverriddenFlag': true,
myDestructuredObjPlugin: { myOverriddenFlag: true }, myDestructuredObjPlugin: { myOverriddenFlag: true },
}, },
initialFeatureFlags: {},
}); });
featureFlagsService.setup({ injectedMetadata }); featureFlagsService.setup({ injectedMetadata });
startContract = await featureFlagsService.start(); startContract = await featureFlagsService.start();

View file

@ -24,7 +24,7 @@ import { get } from 'lodash';
/** /**
* setup method dependencies * setup method dependencies
* @private * @internal
*/ */
export interface FeatureFlagsSetupDeps { export interface FeatureFlagsSetupDeps {
/** /**
@ -35,7 +35,7 @@ export interface FeatureFlagsSetupDeps {
/** /**
* The browser-side Feature Flags Service * The browser-side Feature Flags Service
* @private * @internal
*/ */
export class FeatureFlagsService { export class FeatureFlagsService {
private readonly featureFlagsClient: Client; private readonly featureFlagsClient: Client;
@ -64,6 +64,7 @@ export class FeatureFlagsService {
this.overrides = featureFlagsInjectedMetadata.overrides; this.overrides = featureFlagsInjectedMetadata.overrides;
} }
return { return {
getInitialFeatureFlags: () => featureFlagsInjectedMetadata?.initialFeatureFlags ?? {},
setProvider: (provider) => { setProvider: (provider) => {
if (this.isProviderReadyPromise) { if (this.isProviderReadyPromise) {
throw new Error('A provider has already been set. This API cannot be called twice.'); throw new Error('A provider has already been set. This API cannot be called twice.');
@ -159,7 +160,7 @@ export class FeatureFlagsService {
/** /**
* Waits for the provider initialization with a timeout to avoid holding the page load for too long * Waits for the provider initialization with a timeout to avoid holding the page load for too long
* @private * @internal
*/ */
private async waitForProviderInitialization() { private async waitForProviderInitialization() {
// Adding a timeout here to avoid hanging the start for too long if the provider is unresponsive // Adding a timeout here to avoid hanging the start for too long if the provider is unresponsive
@ -185,7 +186,7 @@ export class FeatureFlagsService {
* @param evaluationFn The actual evaluation API * @param evaluationFn The actual evaluation API
* @param flagName The name of the flag to evaluate * @param flagName The name of the flag to evaluate
* @param fallbackValue The fallback value * @param fallbackValue The fallback value
* @private * @internal
*/ */
private evaluateFlag<T extends string | boolean | number>( private evaluateFlag<T extends string | boolean | number>(
evaluationFn: (flagName: string, fallbackValue: T) => T, evaluationFn: (flagName: string, fallbackValue: T) => T,
@ -206,7 +207,7 @@ export class FeatureFlagsService {
/** /**
* Formats the provided context to fulfill the expected multi-context structure. * Formats the provided context to fulfill the expected multi-context structure.
* @param contextToAppend The {@link EvaluationContext} to append. * @param contextToAppend The {@link EvaluationContext} to append.
* @private * @internal
*/ */
private async appendContext(contextToAppend: EvaluationContext): Promise<void> { private async appendContext(contextToAppend: EvaluationContext): Promise<void> {
// If no kind provided, default to the project|deployment level. // If no kind provided, default to the project|deployment level.

View file

@ -14,6 +14,7 @@ import { of } from 'rxjs';
const createFeatureFlagsSetup = (): jest.Mocked<FeatureFlagsSetup> => { const createFeatureFlagsSetup = (): jest.Mocked<FeatureFlagsSetup> => {
return { return {
getInitialFeatureFlags: jest.fn().mockImplementation(() => ({})),
setProvider: jest.fn(), setProvider: jest.fn(),
appendContext: jest.fn().mockImplementation(Promise.resolve), appendContext: jest.fn().mockImplementation(Promise.resolve),
}; };

View file

@ -87,6 +87,12 @@ export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & {
* @public * @public
*/ */
export interface FeatureFlagsSetup { export interface FeatureFlagsSetup {
/**
* Used for bootstrapping the browser-side client with a seed of the feature flags for faster load-times.
* @remarks It shouldn't be used to evaluate the feature flags because it won't report usage.
*/
getInitialFeatureFlags: () => Record<string, unknown>;
/** /**
* Registers an OpenFeature provider to talk to the * Registers an OpenFeature provider to talk to the
* 3rd-party service that manages the Feature Flags. * 3rd-party service that manages the Feature Flags.

View file

@ -12,7 +12,7 @@ import { schema } from '@kbn/config-schema';
/** /**
* The definition of the validation config schema * The definition of the validation config schema
* @private * @internal
*/ */
const configSchema = schema.object({ const configSchema = schema.object({
overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())), overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())),
@ -20,7 +20,7 @@ const configSchema = schema.object({
/** /**
* Type definition of the Feature Flags configuration * Type definition of the Feature Flags configuration
* @private * @internal
*/ */
export interface FeatureFlagsConfig { export interface FeatureFlagsConfig {
overrides?: Record<string, unknown>; overrides?: Record<string, unknown>;
@ -28,7 +28,7 @@ export interface FeatureFlagsConfig {
/** /**
* Config descriptor for the feature flags service * Config descriptor for the feature flags service
* @private * @internal
*/ */
export const featureFlagsConfig: ServiceConfigDescriptor<FeatureFlagsConfig> = { export const featureFlagsConfig: ServiceConfigDescriptor<FeatureFlagsConfig> = {
/** /**

View file

@ -320,4 +320,19 @@ describe('FeatureFlagsService Server', () => {
'myDestructuredObjPlugin.myOverriddenFlag': true, 'myDestructuredObjPlugin.myOverriddenFlag': true,
}); });
}); });
describe('bootstrapping helpers', () => {
test('return empty initial feature flags if no getter registered', async () => {
const { getInitialFeatureFlags } = featureFlagsService.setup();
await expect(getInitialFeatureFlags()).resolves.toEqual({});
});
test('calls the getter when registered', async () => {
const { setInitialFeatureFlagsGetter, getInitialFeatureFlags } = featureFlagsService.setup();
const mockGetter = jest.fn().mockResolvedValue({ myFlag: true });
setInitialFeatureFlagsGetter(mockGetter);
await expect(getInitialFeatureFlags()).resolves.toEqual({ myFlag: true });
expect(mockGetter).toHaveBeenCalledTimes(1);
});
});
}); });

View file

@ -26,24 +26,30 @@ import {
import deepMerge from 'deepmerge'; import deepMerge from 'deepmerge';
import { filter, switchMap, startWith, Subject, BehaviorSubject, pairwise, takeUntil } from 'rxjs'; import { filter, switchMap, startWith, Subject, BehaviorSubject, pairwise, takeUntil } from 'rxjs';
import { get } from 'lodash'; import { get } from 'lodash';
import type { InitialFeatureFlagsGetter } from '@kbn/core-feature-flags-server/src/contracts';
import { createOpenFeatureLogger } from './create_open_feature_logger'; import { createOpenFeatureLogger } from './create_open_feature_logger';
import { setProviderWithRetries } from './set_provider_with_retries'; import { setProviderWithRetries } from './set_provider_with_retries';
import { type FeatureFlagsConfig, featureFlagsConfig } from './feature_flags_config'; import { type FeatureFlagsConfig, featureFlagsConfig } from './feature_flags_config';
/** /**
* Core-internal contract for the setup lifecycle step. * Core-internal contract for the setup lifecycle step.
* @private * @internal
*/ */
export interface InternalFeatureFlagsSetup extends FeatureFlagsSetup { export interface InternalFeatureFlagsSetup extends FeatureFlagsSetup {
/** /**
* Used by the rendering service to share the overrides with the service on the browser side. * Used by the rendering service to share the overrides with the service on the browser side.
*/ */
getOverrides: () => Record<string, unknown>; getOverrides: () => Record<string, unknown>;
/**
* Required to bootstrap the browser-side OpenFeature client with a seed of the feature flags for faster load-times
* and to work-around air-gapped environments.
*/
getInitialFeatureFlags: () => Promise<Record<string, unknown>>;
} }
/** /**
* The server-side Feature Flags Service * The server-side Feature Flags Service
* @private * @internal
*/ */
export class FeatureFlagsService { export class FeatureFlagsService {
private readonly featureFlagsClient: Client; private readonly featureFlagsClient: Client;
@ -51,6 +57,7 @@ export class FeatureFlagsService {
private readonly stop$ = new Subject<void>(); private readonly stop$ = new Subject<void>();
private readonly overrides$ = new BehaviorSubject<Record<string, unknown>>({}); private readonly overrides$ = new BehaviorSubject<Record<string, unknown>>({});
private context: MultiContextEvaluationContext = { kind: 'multi' }; private context: MultiContextEvaluationContext = { kind: 'multi' };
private initialFeatureFlagsGetter: InitialFeatureFlagsGetter = async () => ({});
/** /**
* The core service's constructor * The core service's constructor
@ -77,6 +84,10 @@ export class FeatureFlagsService {
return { return {
getOverrides: () => this.overrides$.value, getOverrides: () => this.overrides$.value,
getInitialFeatureFlags: () => this.initialFeatureFlagsGetter(),
setInitialFeatureFlagsGetter: (getter: InitialFeatureFlagsGetter) => {
this.initialFeatureFlagsGetter = getter;
},
setProvider: (provider) => { setProvider: (provider) => {
if (OpenFeature.providerMetadata !== NOOP_PROVIDER.metadata) { if (OpenFeature.providerMetadata !== NOOP_PROVIDER.metadata) {
throw new Error('A provider has already been set. This API cannot be called twice.'); throw new Error('A provider has already been set. This API cannot be called twice.');
@ -175,7 +186,7 @@ export class FeatureFlagsService {
* @param evaluationFn The actual evaluation API * @param evaluationFn The actual evaluation API
* @param flagName The name of the flag to evaluate * @param flagName The name of the flag to evaluate
* @param fallbackValue The fallback value * @param fallbackValue The fallback value
* @private * @internal
*/ */
private async evaluateFlag<T extends string | boolean | number>( private async evaluateFlag<T extends string | boolean | number>(
evaluationFn: (flagName: string, fallbackValue: T) => Promise<T>, evaluationFn: (flagName: string, fallbackValue: T) => Promise<T>,
@ -196,7 +207,7 @@ export class FeatureFlagsService {
/** /**
* Formats the provided context to fulfill the expected multi-context structure. * Formats the provided context to fulfill the expected multi-context structure.
* @param contextToAppend The {@link EvaluationContext} to append. * @param contextToAppend The {@link EvaluationContext} to append.
* @private * @internal
*/ */
private appendContext(contextToAppend: EvaluationContext): void { private appendContext(contextToAppend: EvaluationContext): void {
// If no kind provided, default to the project|deployment level. // If no kind provided, default to the project|deployment level.

View file

@ -23,11 +23,13 @@ const createFeatureFlagsInternalSetup = (): jest.Mocked<InternalFeatureFlagsSetu
return { return {
...createFeatureFlagsSetup(), ...createFeatureFlagsSetup(),
getOverrides: jest.fn().mockReturnValue({}), getOverrides: jest.fn().mockReturnValue({}),
getInitialFeatureFlags: jest.fn().mockImplementation(async () => ({})),
}; };
}; };
const createFeatureFlagsSetup = (): jest.Mocked<FeatureFlagsSetup> => { const createFeatureFlagsSetup = (): jest.Mocked<FeatureFlagsSetup> => {
return { return {
setInitialFeatureFlagsGetter: jest.fn(),
setProvider: jest.fn(), setProvider: jest.fn(),
appendContext: jest.fn(), appendContext: jest.fn(),
}; };

View file

@ -13,6 +13,7 @@ export type {
SingleContextEvaluationContext, SingleContextEvaluationContext,
FeatureFlagsSetup, FeatureFlagsSetup,
FeatureFlagsStart, FeatureFlagsStart,
InitialFeatureFlagsGetter,
} from './src/contracts'; } from './src/contracts';
export type { FeatureFlagDefinition, FeatureFlagDefinitions } from './src/feature_flag_definition'; export type { FeatureFlagDefinition, FeatureFlagDefinitions } from './src/feature_flag_definition';
export type { FeatureFlagsRequestHandlerContext } from './src/request_handler_context'; export type { FeatureFlagsRequestHandlerContext } from './src/request_handler_context';

View file

@ -82,6 +82,12 @@ export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & {
kind?: 'organization' | 'kibana'; kind?: 'organization' | 'kibana';
}; };
/**
* Getter function type to retrieve the initial feature flags.
* @internal
*/
export type InitialFeatureFlagsGetter = () => Promise<Record<string, unknown>>;
/** /**
* Setup contract of the Feature Flags Service * Setup contract of the Feature Flags Service
* @public * @public
@ -101,6 +107,15 @@ export interface FeatureFlagsSetup {
* @public * @public
*/ */
appendContext(contextToAppend: EvaluationContext): void; appendContext(contextToAppend: EvaluationContext): void;
/**
* Registers a getter function that will be used to retrieve the initial feature flags to be injected into the
* browser for faster bootstrapping.
* @param getter A function that returns all the feature flags and their values for this context.
* Ideally, using the underlying API shouldn't track them as actual evaluations.
* @internal
*/
setInitialFeatureFlagsGetter(getter: InitialFeatureFlagsGetter): void;
} }
/** /**

View file

@ -170,12 +170,20 @@ describe('setup.getFeatureFlags()', () => {
overrides: { overrides: {
'my-overridden-flag': 1234, 'my-overridden-flag': 1234,
}, },
initialFeatureFlags: {
'my-initial-flag': true,
},
}, },
}, },
} as unknown as InjectedMetadataParams); } as unknown as InjectedMetadataParams);
const contract = injectedMetadata.setup(); const contract = injectedMetadata.setup();
expect(contract.getFeatureFlags()).toStrictEqual({ overrides: { 'my-overridden-flag': 1234 } }); expect(contract.getFeatureFlags()).toStrictEqual({
overrides: { 'my-overridden-flag': 1234 },
initialFeatureFlags: {
'my-initial-flag': true,
},
});
}); });
it('returns empty injectedMetadata.featureFlags', () => { it('returns empty injectedMetadata.featureFlags', () => {

View file

@ -61,6 +61,7 @@ export interface InternalInjectedMetadataSetup {
getFeatureFlags: () => getFeatureFlags: () =>
| { | {
overrides: Record<string, unknown>; overrides: Record<string, unknown>;
initialFeatureFlags: Record<string, unknown>;
} }
| undefined; | undefined;
} }

View file

@ -66,6 +66,7 @@ export interface InjectedMetadata {
}; };
featureFlags?: { featureFlags?: {
overrides: Record<string, unknown>; overrides: Record<string, unknown>;
initialFeatureFlags: Record<string, unknown>;
}; };
anonymousStatusPage: boolean; anonymousStatusPage: boolean;
i18n: { i18n: {

View file

@ -220,6 +220,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>({
getAsLabels: deps.executionContext.getAsLabels, getAsLabels: deps.executionContext.getAsLabels,
}, },
featureFlags: { featureFlags: {
setInitialFeatureFlagsGetter: deps.featureFlags.setInitialFeatureFlagsGetter,
setProvider: deps.featureFlags.setProvider, setProvider: deps.featureFlags.setProvider,
appendContext: deps.featureFlags.appendContext, appendContext: deps.featureFlags.appendContext,
}, },

View file

@ -40,6 +40,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -126,6 +127,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -208,6 +210,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -294,6 +297,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -376,6 +380,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -458,6 +463,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -544,6 +550,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -626,6 +633,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -708,6 +716,90 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {},
},
"i18n": Object {
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
},
"legacyMetadata": Object {
"globalUiSettings": Object {
"defaults": Object {},
"user": Object {},
},
"uiSettings": Object {
"defaults": Object {
"registered": Object {
"name": "title",
},
},
"user": Object {},
},
},
"logging": Any<Object>,
"publicBaseUrl": "http://myhost.com/mock-server-basepath",
"serverBasePath": "/mock-server-basepath",
"theme": Object {
"darkMode": "theme:darkMode",
"name": "borealis",
"stylesheetPaths": Object {
"dark": Array [
"/style-1.css",
"/style-2.css",
],
"default": Array [
"/style-1.css",
"/style-2.css",
],
},
"version": "v8",
},
"uiPlugins": Array [],
"version": Any<String>,
}
`;
exports[`RenderingService preboot() render() renders initial feature flags 1`] = `
Object {
"anonymousStatusPage": false,
"apmConfig": Object {
"stubApmConfig": true,
},
"assetsHrefBase": "http://foo.bar:1773",
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {},
"csp": Object {
"warnLegacyBrowsers": true,
},
"customBranding": Object {},
"env": Object {
"mode": Object {
"dev": Any<Boolean>,
"name": Any<String>,
"prod": Any<Boolean>,
},
"packageInfo": Object {
"branch": Any<String>,
"buildDate": "2023-05-15T23:12:09.000Z",
"buildFlavor": Any<String>,
"buildNum": Any<Number>,
"buildSha": Any<String>,
"buildShaShort": "XXXXXX",
"dist": Any<Boolean>,
"version": Any<String>,
},
},
"externalUrl": Object {
"policy": Array [
Object {
"allow": true,
},
],
},
"featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -795,6 +887,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -881,6 +974,9 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {
"my-initial-flag": 1234,
},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -968,6 +1064,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -1059,6 +1156,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -1141,6 +1239,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -1228,6 +1327,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -1319,6 +1419,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -1406,6 +1507,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object {}, "overrides": Object {},
}, },
"i18n": Object { "i18n": Object {
@ -1493,6 +1595,7 @@ Object {
], ],
}, },
"featureFlags": Object { "featureFlags": Object {
"initialFeatureFlags": Object {},
"overrides": Object { "overrides": Object {
"my-overridden-flag": 1234, "my-overridden-flag": 1234,
}, },
@ -1536,3 +1639,93 @@ Object {
"version": Any<String>, "version": Any<String>,
} }
`; `;
exports[`RenderingService setup() render() renders initial feature flags 1`] = `
Object {
"anonymousStatusPage": false,
"apmConfig": Object {
"stubApmConfig": true,
},
"assetsHrefBase": "/mock-server-basepath",
"basePath": "/mock-server-basepath",
"branch": Any<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {
"cluster_build_flavor": "default",
"cluster_name": "cluster-name",
"cluster_uuid": "cluster-uuid",
"cluster_version": "8.0.0",
},
"csp": Object {
"warnLegacyBrowsers": true,
},
"customBranding": Object {},
"env": Object {
"mode": Object {
"dev": Any<Boolean>,
"name": Any<String>,
"prod": Any<Boolean>,
},
"packageInfo": Object {
"branch": Any<String>,
"buildDate": "2023-05-15T23:12:09.000Z",
"buildFlavor": Any<String>,
"buildNum": Any<Number>,
"buildSha": Any<String>,
"buildShaShort": "XXXXXX",
"dist": Any<Boolean>,
"version": Any<String>,
},
},
"externalUrl": Object {
"policy": Array [
Object {
"allow": true,
},
],
},
"featureFlags": Object {
"initialFeatureFlags": Object {
"my-initial-flag": 1234,
},
"overrides": Object {},
},
"i18n": Object {
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
},
"legacyMetadata": Object {
"globalUiSettings": Object {
"defaults": Object {},
"user": Object {},
},
"uiSettings": Object {
"defaults": Object {
"registered": Object {
"name": "title",
},
},
"user": Object {},
},
},
"logging": Any<Object>,
"publicBaseUrl": "http://myhost.com/mock-server-basepath",
"serverBasePath": "/mock-server-basepath",
"theme": Object {
"darkMode": "theme:darkMode",
"name": "borealis",
"stylesheetPaths": Object {
"dark": Array [
"/style-1.css",
"/style-2.css",
],
"default": Array [
"/style-1.css",
"/style-2.css",
],
},
"version": "v8",
},
"uiPlugins": Array [],
"version": Any<String>,
}
`;

View file

@ -265,6 +265,19 @@ function renderTestCases(
expect(data).toMatchSnapshot(INJECTED_METADATA); expect(data).toMatchSnapshot(INJECTED_METADATA);
}); });
it('renders initial feature flags', async () => {
mockRenderingSetupDeps.featureFlags.getInitialFeatureFlags.mockResolvedValueOnce({
'my-initial-flag': 1234,
});
const [render] = await getRender();
const content = await render(createKibanaRequest(), uiSettings, {
isAnonymousPage: false,
});
const dom = load(content);
const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""');
expect(data).toMatchSnapshot(INJECTED_METADATA);
});
it('renders "core" with logging config injected', async () => { it('renders "core" with logging config injected', async () => {
const loggingConfig = { const loggingConfig = {
root: { root: {

View file

@ -298,6 +298,7 @@ export class RenderingService {
env, env,
featureFlags: { featureFlags: {
overrides: featureFlags?.getOverrides() || {}, overrides: featureFlags?.getOverrides() || {},
initialFeatureFlags: (await featureFlags?.getInitialFeatureFlags()) || {},
}, },
clusterInfo, clusterInfo,
apmConfig, apmConfig,

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const LaunchDarklyClientProviderMocked = jest.fn();
jest.doMock('@openfeature/launchdarkly-client-provider', () => {
return {
LaunchDarklyClientProvider: LaunchDarklyClientProviderMocked,
};
});

View file

@ -9,6 +9,7 @@ import { duration } from 'moment';
import { coreMock } from '@kbn/core/public/mocks'; import { coreMock } from '@kbn/core/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { LaunchDarklyClientProviderMocked } from './plugin.test.mocks';
import { CloudExperimentsPlugin } from './plugin'; import { CloudExperimentsPlugin } from './plugin';
import { MetadataService } from '../common/metadata_service'; import { MetadataService } from '../common/metadata_service';
@ -69,6 +70,32 @@ describe('Cloud Experiments public plugin', () => {
}) })
).toBeUndefined(); ).toBeUndefined();
}); });
test('leverages the injected initial feature flags when creating the new client', () => {
const coreSetup = coreMock.createSetup();
coreSetup.featureFlags.getInitialFeatureFlags.mockReturnValue({
'my-initial-flag': true,
});
plugin.setup(coreSetup, { cloud: cloudMock.createSetup() });
expect(LaunchDarklyClientProviderMocked).toHaveBeenCalledWith(
'1234',
expect.objectContaining({
bootstrap: { 'my-initial-flag': true },
})
);
});
test('use undefined bootstrap when creating the new client and no provided initial feature flags', () => {
const coreSetup = coreMock.createSetup();
coreSetup.featureFlags.getInitialFeatureFlags.mockReturnValue({});
plugin.setup(coreSetup, { cloud: cloudMock.createSetup() });
expect(LaunchDarklyClientProviderMocked).toHaveBeenCalledWith(
'1234',
expect.objectContaining({
bootstrap: undefined,
})
);
});
}); });
describe('start', () => { describe('start', () => {

View file

@ -73,7 +73,8 @@ export class CloudExperimentsPlugin
logger: this.logger, logger: this.logger,
}); });
const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); const initialFeatureFlags = core.featureFlags.getInitialFeatureFlags();
const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(initialFeatureFlags);
if (launchDarklyOpenFeatureProvider) { if (launchDarklyOpenFeatureProvider) {
core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); core.featureFlags.setProvider(launchDarklyOpenFeatureProvider);
} }
@ -101,13 +102,15 @@ export class CloudExperimentsPlugin
* Sets up the OpenFeature LaunchDarkly provider * Sets up the OpenFeature LaunchDarkly provider
* @private * @private
*/ */
private createOpenFeatureProvider() { private createOpenFeatureProvider(initialFeatureFlags: Record<string, unknown>) {
const { launch_darkly: ldConfig } = this.initializerContext.config.get<{ const { launch_darkly: ldConfig } = this.initializerContext.config.get<{
launch_darkly?: LaunchDarklyClientConfig; launch_darkly?: LaunchDarklyClientConfig;
}>(); }>();
if (!ldConfig) return; if (!ldConfig) return;
const bootstrap = Object.keys(initialFeatureFlags).length > 0 ? initialFeatureFlags : undefined;
return new LaunchDarklyClientProvider(ldConfig.client_id, { return new LaunchDarklyClientProvider(ldConfig.client_id, {
// logger: this.logger.get('launch-darkly'), // logger: this.logger.get('launch-darkly'),
// Using basicLogger for now because we can't limit the level for now if we're using core's logger. // Using basicLogger for now because we can't limit the level for now if we're using core's logger.
@ -120,6 +123,7 @@ export class CloudExperimentsPlugin
? this.initializerContext.env.packageInfo.buildSha ? this.initializerContext.env.packageInfo.buildSha
: this.initializerContext.env.packageInfo.version, : this.initializerContext.env.packageInfo.version,
}, },
bootstrap,
}); });
} }
} }

View file

@ -102,6 +102,15 @@ describe('Cloud Experiments server plugin', () => {
}, },
}); });
}); });
test('registers the initial feature flags getter to enable bootstrapping', async () => {
const coreSetupMock = coreMock.createSetup();
plugin.setup(coreSetupMock, {
cloud: cloudMock.createSetup(),
usageCollection: usageCollectionPluginMock.createSetupContract(),
});
expect(coreSetupMock.featureFlags.setInitialFeatureFlagsGetter).toHaveBeenCalledTimes(1);
});
}); });
describe('start', () => { describe('start', () => {

View file

@ -20,7 +20,7 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server/types'; import type { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server/types';
import { initializeMetadata, MetadataService } from '../common/metadata_service'; import { initializeMetadata, MetadataService } from '../common/metadata_service';
import { registerUsageCollector } from './usage'; import { getAllFlags, registerUsageCollector } from './usage';
import type { CloudExperimentsConfigType } from './config'; import type { CloudExperimentsConfigType } from './config';
interface CloudExperimentsPluginSetupDeps { interface CloudExperimentsPluginSetupDeps {
@ -79,6 +79,12 @@ export class CloudExperimentsPlugin
const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider();
if (launchDarklyOpenFeatureProvider) { if (launchDarklyOpenFeatureProvider) {
core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); core.featureFlags.setProvider(launchDarklyOpenFeatureProvider);
core.featureFlags.setInitialFeatureFlagsGetter(async () => {
const launchDarklyClient = launchDarklyOpenFeatureProvider.getClient();
const context = OpenFeature.getContext();
const { flags } = await getAllFlags(launchDarklyClient, context);
return flags;
});
} }
registerUsageCollector(deps.usageCollection, () => ({ registerUsageCollector(deps.usageCollection, () => ({

View file

@ -5,4 +5,4 @@
* 2.0. * 2.0.
*/ */
export { registerUsageCollector } from './register_usage_collector'; export { registerUsageCollector, getAllFlags } from './register_usage_collector';

View file

@ -60,7 +60,7 @@ export function registerUsageCollector(
); );
} }
async function getAllFlags( export async function getAllFlags(
launchDarklyClient: LDClient, launchDarklyClient: LDClient,
currentContext: EvaluationContext currentContext: EvaluationContext
): Promise<Usage> { ): Promise<Usage> {