diff --git a/src/core/packages/feature-flags/browser-internal/src/feature_flags_service.test.ts b/src/core/packages/feature-flags/browser-internal/src/feature_flags_service.test.ts index ef50b1f0fbe9..94e76f55e066 100644 --- a/src/core/packages/feature-flags/browser-internal/src/feature_flags_service.test.ts +++ b/src/core/packages/feature-flags/browser-internal/src/feature_flags_service.test.ts @@ -249,6 +249,7 @@ describe('FeatureFlagsService Browser', () => { 'myPlugin.myOverriddenFlag': true, myDestructuredObjPlugin: { myOverriddenFlag: true }, }, + initialFeatureFlags: {}, }); featureFlagsService.setup({ injectedMetadata }); startContract = await featureFlagsService.start(); diff --git a/src/core/packages/feature-flags/browser-internal/src/feature_flags_service.ts b/src/core/packages/feature-flags/browser-internal/src/feature_flags_service.ts index e616dd359204..3a5fa5b40855 100644 --- a/src/core/packages/feature-flags/browser-internal/src/feature_flags_service.ts +++ b/src/core/packages/feature-flags/browser-internal/src/feature_flags_service.ts @@ -24,7 +24,7 @@ import { get } from 'lodash'; /** * setup method dependencies - * @private + * @internal */ export interface FeatureFlagsSetupDeps { /** @@ -35,7 +35,7 @@ export interface FeatureFlagsSetupDeps { /** * The browser-side Feature Flags Service - * @private + * @internal */ export class FeatureFlagsService { private readonly featureFlagsClient: Client; @@ -64,6 +64,7 @@ export class FeatureFlagsService { this.overrides = featureFlagsInjectedMetadata.overrides; } return { + getInitialFeatureFlags: () => featureFlagsInjectedMetadata?.initialFeatureFlags ?? {}, setProvider: (provider) => { if (this.isProviderReadyPromise) { 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 - * @private + * @internal */ private async waitForProviderInitialization() { // 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 flagName The name of the flag to evaluate * @param fallbackValue The fallback value - * @private + * @internal */ private evaluateFlag( evaluationFn: (flagName: string, fallbackValue: T) => T, @@ -206,7 +207,7 @@ export class FeatureFlagsService { /** * Formats the provided context to fulfill the expected multi-context structure. * @param contextToAppend The {@link EvaluationContext} to append. - * @private + * @internal */ private async appendContext(contextToAppend: EvaluationContext): Promise { // If no kind provided, default to the project|deployment level. diff --git a/src/core/packages/feature-flags/browser-mocks/index.ts b/src/core/packages/feature-flags/browser-mocks/index.ts index ad8cdae6a5ef..803f8d5042a9 100644 --- a/src/core/packages/feature-flags/browser-mocks/index.ts +++ b/src/core/packages/feature-flags/browser-mocks/index.ts @@ -14,6 +14,7 @@ import { of } from 'rxjs'; const createFeatureFlagsSetup = (): jest.Mocked => { return { + getInitialFeatureFlags: jest.fn().mockImplementation(() => ({})), setProvider: jest.fn(), appendContext: jest.fn().mockImplementation(Promise.resolve), }; diff --git a/src/core/packages/feature-flags/browser/src/types.ts b/src/core/packages/feature-flags/browser/src/types.ts index 844675aab460..cac8cb62bc42 100644 --- a/src/core/packages/feature-flags/browser/src/types.ts +++ b/src/core/packages/feature-flags/browser/src/types.ts @@ -87,6 +87,12 @@ export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & { * @public */ 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; + /** * Registers an OpenFeature provider to talk to the * 3rd-party service that manages the Feature Flags. diff --git a/src/core/packages/feature-flags/server-internal/src/feature_flags_config.ts b/src/core/packages/feature-flags/server-internal/src/feature_flags_config.ts index fe6725456806..c9c1c09e6cee 100644 --- a/src/core/packages/feature-flags/server-internal/src/feature_flags_config.ts +++ b/src/core/packages/feature-flags/server-internal/src/feature_flags_config.ts @@ -12,7 +12,7 @@ import { schema } from '@kbn/config-schema'; /** * The definition of the validation config schema - * @private + * @internal */ const configSchema = schema.object({ overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())), @@ -20,7 +20,7 @@ const configSchema = schema.object({ /** * Type definition of the Feature Flags configuration - * @private + * @internal */ export interface FeatureFlagsConfig { overrides?: Record; @@ -28,7 +28,7 @@ export interface FeatureFlagsConfig { /** * Config descriptor for the feature flags service - * @private + * @internal */ export const featureFlagsConfig: ServiceConfigDescriptor = { /** diff --git a/src/core/packages/feature-flags/server-internal/src/feature_flags_service.test.ts b/src/core/packages/feature-flags/server-internal/src/feature_flags_service.test.ts index 0db6e1cf0c14..11722ccad756 100644 --- a/src/core/packages/feature-flags/server-internal/src/feature_flags_service.test.ts +++ b/src/core/packages/feature-flags/server-internal/src/feature_flags_service.test.ts @@ -320,4 +320,19 @@ describe('FeatureFlagsService Server', () => { '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); + }); + }); }); diff --git a/src/core/packages/feature-flags/server-internal/src/feature_flags_service.ts b/src/core/packages/feature-flags/server-internal/src/feature_flags_service.ts index b87c526e9775..30b74952cc04 100644 --- a/src/core/packages/feature-flags/server-internal/src/feature_flags_service.ts +++ b/src/core/packages/feature-flags/server-internal/src/feature_flags_service.ts @@ -26,24 +26,30 @@ import { import deepMerge from 'deepmerge'; import { filter, switchMap, startWith, Subject, BehaviorSubject, pairwise, takeUntil } from 'rxjs'; import { get } from 'lodash'; +import type { InitialFeatureFlagsGetter } from '@kbn/core-feature-flags-server/src/contracts'; import { createOpenFeatureLogger } from './create_open_feature_logger'; import { setProviderWithRetries } from './set_provider_with_retries'; import { type FeatureFlagsConfig, featureFlagsConfig } from './feature_flags_config'; /** * Core-internal contract for the setup lifecycle step. - * @private + * @internal */ export interface InternalFeatureFlagsSetup extends FeatureFlagsSetup { /** * Used by the rendering service to share the overrides with the service on the browser side. */ getOverrides: () => Record; + /** + * 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>; } /** * The server-side Feature Flags Service - * @private + * @internal */ export class FeatureFlagsService { private readonly featureFlagsClient: Client; @@ -51,6 +57,7 @@ export class FeatureFlagsService { private readonly stop$ = new Subject(); private readonly overrides$ = new BehaviorSubject>({}); private context: MultiContextEvaluationContext = { kind: 'multi' }; + private initialFeatureFlagsGetter: InitialFeatureFlagsGetter = async () => ({}); /** * The core service's constructor @@ -77,6 +84,10 @@ export class FeatureFlagsService { return { getOverrides: () => this.overrides$.value, + getInitialFeatureFlags: () => this.initialFeatureFlagsGetter(), + setInitialFeatureFlagsGetter: (getter: InitialFeatureFlagsGetter) => { + this.initialFeatureFlagsGetter = getter; + }, setProvider: (provider) => { if (OpenFeature.providerMetadata !== NOOP_PROVIDER.metadata) { 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 flagName The name of the flag to evaluate * @param fallbackValue The fallback value - * @private + * @internal */ private async evaluateFlag( evaluationFn: (flagName: string, fallbackValue: T) => Promise, @@ -196,7 +207,7 @@ export class FeatureFlagsService { /** * Formats the provided context to fulfill the expected multi-context structure. * @param contextToAppend The {@link EvaluationContext} to append. - * @private + * @internal */ private appendContext(contextToAppend: EvaluationContext): void { // If no kind provided, default to the project|deployment level. diff --git a/src/core/packages/feature-flags/server-mocks/index.ts b/src/core/packages/feature-flags/server-mocks/index.ts index 182f6dbc2110..80da6fd951be 100644 --- a/src/core/packages/feature-flags/server-mocks/index.ts +++ b/src/core/packages/feature-flags/server-mocks/index.ts @@ -23,11 +23,13 @@ const createFeatureFlagsInternalSetup = (): jest.Mocked ({})), }; }; const createFeatureFlagsSetup = (): jest.Mocked => { return { + setInitialFeatureFlagsGetter: jest.fn(), setProvider: jest.fn(), appendContext: jest.fn(), }; diff --git a/src/core/packages/feature-flags/server/index.ts b/src/core/packages/feature-flags/server/index.ts index 7538b68686cd..a30128f32110 100644 --- a/src/core/packages/feature-flags/server/index.ts +++ b/src/core/packages/feature-flags/server/index.ts @@ -13,6 +13,7 @@ export type { SingleContextEvaluationContext, FeatureFlagsSetup, FeatureFlagsStart, + InitialFeatureFlagsGetter, } from './src/contracts'; export type { FeatureFlagDefinition, FeatureFlagDefinitions } from './src/feature_flag_definition'; export type { FeatureFlagsRequestHandlerContext } from './src/request_handler_context'; diff --git a/src/core/packages/feature-flags/server/src/contracts.ts b/src/core/packages/feature-flags/server/src/contracts.ts index 34fc3a3a7338..5ef128f74e87 100644 --- a/src/core/packages/feature-flags/server/src/contracts.ts +++ b/src/core/packages/feature-flags/server/src/contracts.ts @@ -82,6 +82,12 @@ export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & { kind?: 'organization' | 'kibana'; }; +/** + * Getter function type to retrieve the initial feature flags. + * @internal + */ +export type InitialFeatureFlagsGetter = () => Promise>; + /** * Setup contract of the Feature Flags Service * @public @@ -101,6 +107,15 @@ export interface FeatureFlagsSetup { * @public */ 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; } /** diff --git a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts index dd315b38fe3c..e933b806cd16 100644 --- a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts +++ b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts @@ -170,12 +170,20 @@ describe('setup.getFeatureFlags()', () => { overrides: { 'my-overridden-flag': 1234, }, + initialFeatureFlags: { + 'my-initial-flag': true, + }, }, }, } as unknown as InjectedMetadataParams); 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', () => { diff --git a/src/core/packages/injected-metadata/browser-internal/src/types.ts b/src/core/packages/injected-metadata/browser-internal/src/types.ts index 244b99da0c20..11acfe631aef 100644 --- a/src/core/packages/injected-metadata/browser-internal/src/types.ts +++ b/src/core/packages/injected-metadata/browser-internal/src/types.ts @@ -61,6 +61,7 @@ export interface InternalInjectedMetadataSetup { getFeatureFlags: () => | { overrides: Record; + initialFeatureFlags: Record; } | undefined; } diff --git a/src/core/packages/injected-metadata/common-internal/src/types.ts b/src/core/packages/injected-metadata/common-internal/src/types.ts index ce88073ca7ba..3bcc9e589cac 100644 --- a/src/core/packages/injected-metadata/common-internal/src/types.ts +++ b/src/core/packages/injected-metadata/common-internal/src/types.ts @@ -66,6 +66,7 @@ export interface InjectedMetadata { }; featureFlags?: { overrides: Record; + initialFeatureFlags: Record; }; anonymousStatusPage: boolean; i18n: { diff --git a/src/core/packages/plugins/server-internal/src/plugin_context.ts b/src/core/packages/plugins/server-internal/src/plugin_context.ts index 09100ace6caf..3bbc3c066859 100644 --- a/src/core/packages/plugins/server-internal/src/plugin_context.ts +++ b/src/core/packages/plugins/server-internal/src/plugin_context.ts @@ -220,6 +220,7 @@ export function createPluginSetupContext({ getAsLabels: deps.executionContext.getAsLabels, }, featureFlags: { + setInitialFeatureFlagsGetter: deps.featureFlags.setInitialFeatureFlagsGetter, setProvider: deps.featureFlags.setProvider, appendContext: deps.featureFlags.appendContext, }, diff --git a/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap b/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap index 79740bdef49b..d0e71ef2a5f0 100644 --- a/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap @@ -40,6 +40,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -126,6 +127,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -208,6 +210,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -294,6 +297,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -376,6 +380,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -458,6 +463,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -544,6 +550,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -626,6 +633,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -708,6 +716,90 @@ 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, + "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, +} +`; + +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, + "buildNumber": Any, + "clusterInfo": Object {}, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "customBranding": Object {}, + "env": Object { + "mode": Object { + "dev": Any, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildDate": "2023-05-15T23:12:09.000Z", + "buildFlavor": Any, + "buildNum": Any, + "buildSha": Any, + "buildShaShort": "XXXXXX", + "dist": Any, + "version": Any, + }, + }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, + "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -795,6 +887,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -881,6 +974,9 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object { + "my-initial-flag": 1234, + }, "overrides": Object {}, }, "i18n": Object { @@ -968,6 +1064,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -1059,6 +1156,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -1141,6 +1239,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -1228,6 +1327,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -1319,6 +1419,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -1406,6 +1507,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object {}, }, "i18n": Object { @@ -1493,6 +1595,7 @@ Object { ], }, "featureFlags": Object { + "initialFeatureFlags": Object {}, "overrides": Object { "my-overridden-flag": 1234, }, @@ -1536,3 +1639,93 @@ Object { "version": Any, } `; + +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, + "buildNumber": Any, + "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, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildDate": "2023-05-15T23:12:09.000Z", + "buildFlavor": Any, + "buildNum": Any, + "buildSha": Any, + "buildShaShort": "XXXXXX", + "dist": Any, + "version": Any, + }, + }, + "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, + "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, +} +`; diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts index fd2b47fe9128..429c2edb0cd4 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts +++ b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts @@ -265,6 +265,19 @@ function renderTestCases( 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 () => { const loggingConfig = { root: { diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.tsx b/src/core/packages/rendering/server-internal/src/rendering_service.tsx index 407d8dff7060..999c0d045a0a 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.tsx +++ b/src/core/packages/rendering/server-internal/src/rendering_service.tsx @@ -298,6 +298,7 @@ export class RenderingService { env, featureFlags: { overrides: featureFlags?.getOverrides() || {}, + initialFeatureFlags: (await featureFlags?.getInitialFeatureFlags()) || {}, }, clusterInfo, apmConfig, diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.test.mocks.ts b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.test.mocks.ts new file mode 100644 index 000000000000..f277f325f5bc --- /dev/null +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.test.mocks.ts @@ -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, + }; +}); diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.test.ts b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.test.ts index 59a20b198e70..999c56dabcb8 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.test.ts +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.test.ts @@ -9,6 +9,7 @@ import { duration } from 'moment'; import { coreMock } from '@kbn/core/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { LaunchDarklyClientProviderMocked } from './plugin.test.mocks'; import { CloudExperimentsPlugin } from './plugin'; import { MetadataService } from '../common/metadata_service'; @@ -69,6 +70,32 @@ describe('Cloud Experiments public plugin', () => { }) ).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', () => { diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.ts b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.ts index ee95019e6fa1..ceb9691adf95 100755 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.ts +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/public/plugin.ts @@ -73,7 +73,8 @@ export class CloudExperimentsPlugin logger: this.logger, }); - const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); + const initialFeatureFlags = core.featureFlags.getInitialFeatureFlags(); + const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(initialFeatureFlags); if (launchDarklyOpenFeatureProvider) { core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); } @@ -101,13 +102,15 @@ export class CloudExperimentsPlugin * Sets up the OpenFeature LaunchDarkly provider * @private */ - private createOpenFeatureProvider() { + private createOpenFeatureProvider(initialFeatureFlags: Record) { const { launch_darkly: ldConfig } = this.initializerContext.config.get<{ launch_darkly?: LaunchDarklyClientConfig; }>(); if (!ldConfig) return; + const bootstrap = Object.keys(initialFeatureFlags).length > 0 ? initialFeatureFlags : undefined; + return new LaunchDarklyClientProvider(ldConfig.client_id, { // 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. @@ -120,6 +123,7 @@ export class CloudExperimentsPlugin ? this.initializerContext.env.packageInfo.buildSha : this.initializerContext.env.packageInfo.version, }, + bootstrap, }); } } diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/plugin.test.ts b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/plugin.test.ts index 0b52f8686bbc..3c340fc97a43 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/plugin.test.ts +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/plugin.test.ts @@ -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', () => { diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/plugin.ts b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/plugin.ts index fa9de11b0dfc..9841d86cf356 100755 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/plugin.ts +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/plugin.ts @@ -20,7 +20,7 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server/types'; import { initializeMetadata, MetadataService } from '../common/metadata_service'; -import { registerUsageCollector } from './usage'; +import { getAllFlags, registerUsageCollector } from './usage'; import type { CloudExperimentsConfigType } from './config'; interface CloudExperimentsPluginSetupDeps { @@ -79,6 +79,12 @@ export class CloudExperimentsPlugin const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); if (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, () => ({ diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/usage/index.ts b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/usage/index.ts index 59f577ba20b7..ba0ac8bac860 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/usage/index.ts +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/usage/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { registerUsageCollector } from './register_usage_collector'; +export { registerUsageCollector, getAllFlags } from './register_usage_collector'; diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.ts b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.ts index 599ba431b4a3..07e9096debbc 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.ts +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.ts @@ -60,7 +60,7 @@ export function registerUsageCollector( ); } -async function getAllFlags( +export async function getAllFlags( launchDarklyClient: LDClient, currentContext: EvaluationContext ): Promise {