mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 11:05:39 -04:00
[Feature Flags] Client-side bootstrapping (#224258)
This commit is contained in:
parent
8d1204bb5f
commit
fd7f85149c
24 changed files with 348 additions and 18 deletions
|
@ -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();
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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> = {
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -61,6 +61,7 @@ export interface InternalInjectedMetadataSetup {
|
||||||
getFeatureFlags: () =>
|
getFeatureFlags: () =>
|
||||||
| {
|
| {
|
||||||
overrides: Record<string, unknown>;
|
overrides: Record<string, unknown>;
|
||||||
|
initialFeatureFlags: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
});
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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, () => ({
|
||||||
|
|
|
@ -5,4 +5,4 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { registerUsageCollector } from './register_usage_collector';
|
export { registerUsageCollector, getAllFlags } from './register_usage_collector';
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue