mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Feature Flags Service] Hello world 👋 (#188562)
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Jean-Louis Leysens <jloleysens@gmail.com>
This commit is contained in:
parent
38d6143f72
commit
02ce1b9101
164 changed files with 3605 additions and 1941 deletions
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
|
@ -183,6 +183,12 @@ packages/core/execution-context/core-execution-context-server-mocks @elastic/kib
|
|||
packages/core/fatal-errors/core-fatal-errors-browser @elastic/kibana-core
|
||||
packages/core/fatal-errors/core-fatal-errors-browser-internal @elastic/kibana-core
|
||||
packages/core/fatal-errors/core-fatal-errors-browser-mocks @elastic/kibana-core
|
||||
packages/core/feature-flags/core-feature-flags-browser @elastic/kibana-core
|
||||
packages/core/feature-flags/core-feature-flags-browser-internal @elastic/kibana-core
|
||||
packages/core/feature-flags/core-feature-flags-browser-mocks @elastic/kibana-core
|
||||
packages/core/feature-flags/core-feature-flags-server @elastic/kibana-core
|
||||
packages/core/feature-flags/core-feature-flags-server-internal @elastic/kibana-core
|
||||
packages/core/feature-flags/core-feature-flags-server-mocks @elastic/kibana-core
|
||||
test/plugin_functional/plugins/core_history_block @elastic/kibana-core
|
||||
packages/core/http/core-http-browser @elastic/kibana-core
|
||||
packages/core/http/core-http-browser-internal @elastic/kibana-core
|
||||
|
@ -453,6 +459,7 @@ examples/expressions_explorer @elastic/kibana-visualizations
|
|||
src/plugins/expressions @elastic/kibana-visualizations
|
||||
packages/kbn-failed-test-reporter-cli @elastic/kibana-operations @elastic/appex-qa
|
||||
examples/feature_control_examples @elastic/kibana-security
|
||||
examples/feature_flags_example @elastic/kibana-core
|
||||
x-pack/test/plugin_api_integration/plugins/feature_usage_test @elastic/kibana-security
|
||||
x-pack/plugins/features @elastic/kibana-core
|
||||
x-pack/test/functional_execution_context/plugins/alerts @elastic/kibana-core
|
||||
|
|
|
@ -136,10 +136,6 @@
|
|||
},
|
||||
{
|
||||
"id": "kibDevDocsEmbeddables"
|
||||
},
|
||||
{
|
||||
"id": "kibCloudExperimentsPlugin",
|
||||
"label": "A/B testing on Elastic Cloud"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -205,6 +201,10 @@
|
|||
},
|
||||
{
|
||||
"id": "kibDevTutorialCcsSetup"
|
||||
},
|
||||
{
|
||||
"id": "kibFeatureFlagsService",
|
||||
"label": "Feature Flags"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -646,4 +646,4 @@
|
|||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -499,8 +499,8 @@ The plugin exposes the static DefaultEditorController class to consume.
|
|||
|
||||
|
||||
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx[cloudExperiments]
|
||||
|[!WARNING]
|
||||
These APIs are deprecated and should not be used as we're working on a replacement Core Feature Flags Service that will arrive soon.
|
||||
|[!NOTE]
|
||||
This plugin no-longer exposes any evaluation APIs. Refer to <DocLink id="kibFeatureFlagsService" /> for more information about how to interact with feature flags.
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_full_story/README.md[cloudFullStory]
|
||||
|
|
5
examples/feature_flags_example/README.md
Executable file
5
examples/feature_flags_example/README.md
Executable file
|
@ -0,0 +1,5 @@
|
|||
# featureFlagsExample
|
||||
|
||||
This plugin's goal is to demonstrate how to use the core feature flags service.
|
||||
|
||||
Refer to [the docs](../../packages/core/feature-flags/README.mdx) to know more.
|
12
examples/feature_flags_example/common/feature_flags.ts
Normal file
12
examples/feature_flags_example/common/feature_flags.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export const FeatureFlagExampleBoolean = 'example-boolean';
|
||||
export const FeatureFlagExampleString = 'example-string';
|
||||
export const FeatureFlagExampleNumber = 'example-number';
|
11
examples/feature_flags_example/common/index.ts
Normal file
11
examples/feature_flags_example/common/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export const PLUGIN_ID = 'featureFlagsExample';
|
||||
export const PLUGIN_NAME = 'Feature Flags Example';
|
13
examples/feature_flags_example/kibana.jsonc
Normal file
13
examples/feature_flags_example/kibana.jsonc
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"type": "plugin",
|
||||
"id": "@kbn/feature-flags-example-plugin",
|
||||
"owner": "@elastic/kibana-core",
|
||||
"description": "Plugin that shows how to make use of the feature flags core service.",
|
||||
"plugin": {
|
||||
"id": "featureFlagsExample",
|
||||
"server": true,
|
||||
"browser": true,
|
||||
"requiredPlugins": ["developerExamples"],
|
||||
"optionalPlugins": []
|
||||
}
|
||||
}
|
33
examples/feature_flags_example/public/application.tsx
Normal file
33
examples/feature_flags_example/public/application.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { AppMountParameters, CoreStart } from '@kbn/core/public';
|
||||
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
|
||||
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
|
||||
import { FeatureFlagsExampleApp } from './components/app';
|
||||
|
||||
export const renderApp = (coreStart: CoreStart, { element }: AppMountParameters) => {
|
||||
const { notifications, http, featureFlags } = coreStart;
|
||||
ReactDOM.render(
|
||||
<KibanaRootContextProvider {...coreStart}>
|
||||
<KibanaPageTemplate>
|
||||
<FeatureFlagsExampleApp
|
||||
featureFlags={featureFlags}
|
||||
notifications={notifications}
|
||||
http={http}
|
||||
/>
|
||||
</KibanaPageTemplate>
|
||||
</KibanaRootContextProvider>,
|
||||
element
|
||||
);
|
||||
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
};
|
91
examples/feature_flags_example/public/components/app.tsx
Normal file
91
examples/feature_flags_example/public/components/app.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiHorizontalRule,
|
||||
EuiPageTemplate,
|
||||
EuiTitle,
|
||||
EuiText,
|
||||
EuiLink,
|
||||
EuiListGroup,
|
||||
EuiListGroupItem,
|
||||
} from '@elastic/eui';
|
||||
import type { CoreStart, FeatureFlagsStart } from '@kbn/core/public';
|
||||
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import {
|
||||
FeatureFlagExampleBoolean,
|
||||
FeatureFlagExampleNumber,
|
||||
FeatureFlagExampleString,
|
||||
} from '../../common/feature_flags';
|
||||
import { PLUGIN_NAME } from '../../common';
|
||||
|
||||
interface FeatureFlagsExampleAppDeps {
|
||||
featureFlags: FeatureFlagsStart;
|
||||
notifications: CoreStart['notifications'];
|
||||
http: CoreStart['http'];
|
||||
}
|
||||
|
||||
export const FeatureFlagsExampleApp = ({ featureFlags }: FeatureFlagsExampleAppDeps) => {
|
||||
// Fetching the feature flags synchronously
|
||||
const bool = featureFlags.getBooleanValue(FeatureFlagExampleBoolean, false);
|
||||
const str = featureFlags.getStringValue(FeatureFlagExampleString, 'red');
|
||||
const num = featureFlags.getNumberValue(FeatureFlagExampleNumber, 1);
|
||||
|
||||
// Use React Hooks to observe feature flags changes
|
||||
const bool$ = useObservable(featureFlags.getBooleanValue$(FeatureFlagExampleBoolean, false));
|
||||
const str$ = useObservable(featureFlags.getStringValue$(FeatureFlagExampleString, 'red'));
|
||||
const num$ = useObservable(featureFlags.getNumberValue$(FeatureFlagExampleNumber, 1));
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiPageTemplate>
|
||||
<EuiPageTemplate.Header>
|
||||
<EuiTitle size="l">
|
||||
<h1>{PLUGIN_NAME}</h1>
|
||||
</EuiTitle>
|
||||
</EuiPageTemplate.Header>
|
||||
<EuiPageTemplate.Section>
|
||||
<EuiTitle>
|
||||
<h2>Demo of the feature flags service</h2>
|
||||
</EuiTitle>
|
||||
<EuiText>
|
||||
<p>
|
||||
To learn more, refer to{' '}
|
||||
<EuiLink
|
||||
href={'https://docs.elastic.dev/kibana-dev-docs/tutorials/feature-flags-service'}
|
||||
>
|
||||
the docs
|
||||
</EuiLink>
|
||||
.
|
||||
</p>
|
||||
<EuiHorizontalRule />
|
||||
<EuiListGroup>
|
||||
<p>
|
||||
The feature flags are:
|
||||
<EuiListGroupItem label={`${FeatureFlagExampleBoolean}: ${bool}`} />
|
||||
<EuiListGroupItem label={`${FeatureFlagExampleString}: ${str}`} />
|
||||
<EuiListGroupItem label={`${FeatureFlagExampleNumber}: ${num}`} />
|
||||
</p>
|
||||
</EuiListGroup>
|
||||
<EuiListGroup>
|
||||
<p>
|
||||
The <strong>observed</strong> feature flags are:
|
||||
<EuiListGroupItem label={`${FeatureFlagExampleBoolean}: ${bool$}`} />
|
||||
<EuiListGroupItem label={`${FeatureFlagExampleString}: ${str$}`} />
|
||||
<EuiListGroupItem label={`${FeatureFlagExampleNumber}: ${num$}`} />
|
||||
</p>
|
||||
</EuiListGroup>
|
||||
</EuiText>
|
||||
</EuiPageTemplate.Section>
|
||||
</EuiPageTemplate>
|
||||
</>
|
||||
);
|
||||
};
|
14
examples/feature_flags_example/public/index.ts
Normal file
14
examples/feature_flags_example/public/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { FeatureFlagsExamplePlugin } from './plugin';
|
||||
|
||||
export function plugin() {
|
||||
return new FeatureFlagsExamplePlugin();
|
||||
}
|
40
examples/feature_flags_example/public/plugin.ts
Normal file
40
examples/feature_flags_example/public/plugin.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
|
||||
import { AppPluginSetupDependencies } from './types';
|
||||
import { PLUGIN_NAME } from '../common';
|
||||
|
||||
export class FeatureFlagsExamplePlugin implements Plugin {
|
||||
public setup(core: CoreSetup, deps: AppPluginSetupDependencies) {
|
||||
// Register an application into the side navigation menu
|
||||
core.application.register({
|
||||
id: 'featureFlagsExample',
|
||||
title: PLUGIN_NAME,
|
||||
async mount(params: AppMountParameters) {
|
||||
// Load application bundle
|
||||
const { renderApp } = await import('./application');
|
||||
// Get start services as specified in kibana.json
|
||||
const [coreStart] = await core.getStartServices();
|
||||
// Render the application
|
||||
return renderApp(coreStart, params);
|
||||
},
|
||||
});
|
||||
|
||||
deps.developerExamples.register({
|
||||
appId: 'featureFlagsExample',
|
||||
title: PLUGIN_NAME,
|
||||
description: 'Plugin that shows how to make use of the feature flags core service.',
|
||||
});
|
||||
}
|
||||
|
||||
public start(core: CoreStart) {}
|
||||
|
||||
public stop() {}
|
||||
}
|
14
examples/feature_flags_example/public/types.ts
Normal file
14
examples/feature_flags_example/public/types.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
|
||||
|
||||
export interface AppPluginSetupDependencies {
|
||||
developerExamples: DeveloperExamplesSetup;
|
||||
}
|
77
examples/feature_flags_example/server/index.ts
Normal file
77
examples/feature_flags_example/server/index.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { FeatureFlagDefinitions } from '@kbn/core-feature-flags-server';
|
||||
import type { PluginInitializerContext } from '@kbn/core-plugins-server';
|
||||
import {
|
||||
FeatureFlagExampleBoolean,
|
||||
FeatureFlagExampleNumber,
|
||||
FeatureFlagExampleString,
|
||||
} from '../common/feature_flags';
|
||||
|
||||
export const featureFlags: FeatureFlagDefinitions = [
|
||||
{
|
||||
key: FeatureFlagExampleBoolean,
|
||||
name: 'Example boolean',
|
||||
description: 'This is a demo of a boolean flag',
|
||||
tags: ['example', 'my-plugin'],
|
||||
variationType: 'boolean',
|
||||
variations: [
|
||||
{
|
||||
name: 'On',
|
||||
description: 'Auto-hides the bar',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
name: 'Off',
|
||||
description: 'Static always-on',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: FeatureFlagExampleString,
|
||||
name: 'Example string',
|
||||
description: 'This is a demo of a string flag',
|
||||
tags: ['example', 'my-plugin'],
|
||||
variationType: 'string',
|
||||
variations: [
|
||||
{
|
||||
name: 'Pink',
|
||||
value: '#D75489',
|
||||
},
|
||||
{
|
||||
name: 'Turquoise',
|
||||
value: '#65BAAF',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: FeatureFlagExampleNumber,
|
||||
name: 'Example Number',
|
||||
description: 'This is a demo of a number flag',
|
||||
tags: ['example', 'my-plugin'],
|
||||
variationType: 'number',
|
||||
variations: [
|
||||
{
|
||||
name: 'Five',
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
name: 'Ten',
|
||||
value: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function plugin(initializerContext: PluginInitializerContext) {
|
||||
const { FeatureFlagsExamplePlugin } = await import('./plugin');
|
||||
return new FeatureFlagsExamplePlugin(initializerContext);
|
||||
}
|
69
examples/feature_flags_example/server/plugin.ts
Normal file
69
examples/feature_flags_example/server/plugin.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginInitializerContext,
|
||||
CoreSetup,
|
||||
CoreStart,
|
||||
Plugin,
|
||||
Logger,
|
||||
} from '@kbn/core/server';
|
||||
import { combineLatest } from 'rxjs';
|
||||
|
||||
import {
|
||||
FeatureFlagExampleBoolean,
|
||||
FeatureFlagExampleNumber,
|
||||
FeatureFlagExampleString,
|
||||
} from '../common/feature_flags';
|
||||
import { defineRoutes } from './routes';
|
||||
|
||||
export class FeatureFlagsExamplePlugin implements Plugin {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(initializerContext: PluginInitializerContext) {
|
||||
this.logger = initializerContext.logger.get();
|
||||
}
|
||||
|
||||
public setup(core: CoreSetup) {
|
||||
const router = core.http.createRouter();
|
||||
|
||||
// Register server side APIs
|
||||
defineRoutes(router);
|
||||
}
|
||||
|
||||
public start(core: CoreStart) {
|
||||
// Promise form: when we need to fetch it once, like in an HTTP request
|
||||
void Promise.all([
|
||||
core.featureFlags.getBooleanValue(FeatureFlagExampleBoolean, false),
|
||||
core.featureFlags.getStringValue(FeatureFlagExampleString, 'white'),
|
||||
core.featureFlags.getNumberValue(FeatureFlagExampleNumber, 1),
|
||||
]).then(([bool, str, num]) => {
|
||||
this.logger.info(`The feature flags are:
|
||||
- ${FeatureFlagExampleBoolean}: ${bool}
|
||||
- ${FeatureFlagExampleString}: ${str}
|
||||
- ${FeatureFlagExampleNumber}: ${num}
|
||||
`);
|
||||
});
|
||||
|
||||
// Observable form: when we need to react to the changes
|
||||
combineLatest([
|
||||
core.featureFlags.getBooleanValue$(FeatureFlagExampleBoolean, false),
|
||||
core.featureFlags.getStringValue$(FeatureFlagExampleString, 'red'),
|
||||
core.featureFlags.getNumberValue$(FeatureFlagExampleNumber, 1),
|
||||
]).subscribe(([bool, str, num]) => {
|
||||
this.logger.info(`The observed feature flags are:
|
||||
- ${FeatureFlagExampleBoolean}: ${bool}
|
||||
- ${FeatureFlagExampleString}: ${str}
|
||||
- ${FeatureFlagExampleNumber}: ${num}
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
}
|
44
examples/feature_flags_example/server/routes/index.ts
Normal file
44
examples/feature_flags_example/server/routes/index.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { IRouter } from '@kbn/core/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { FeatureFlagExampleNumber } from '../../common/feature_flags';
|
||||
|
||||
export function defineRoutes(router: IRouter) {
|
||||
router.versioned
|
||||
.get({
|
||||
path: '/api/feature_flags_example/example',
|
||||
access: 'public',
|
||||
})
|
||||
.addVersion(
|
||||
{
|
||||
version: '2023-10-31',
|
||||
validate: {
|
||||
response: {
|
||||
200: {
|
||||
body: () =>
|
||||
schema.object({
|
||||
number: schema.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
const { featureFlags } = await context.core;
|
||||
|
||||
return response.ok({
|
||||
body: {
|
||||
number: await featureFlags.getNumberValue(FeatureFlagExampleNumber, 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
24
examples/feature_flags_example/tsconfig.json
Normal file
24
examples/feature_flags_example/tsconfig.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
"common/**/*.ts",
|
||||
"public/**/*.ts",
|
||||
"public/**/*.tsx",
|
||||
"server/**/*.ts",
|
||||
"../../typings/**/*"
|
||||
],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/shared-ux-page-kibana-template",
|
||||
"@kbn/react-kibana-context-root",
|
||||
"@kbn/core-feature-flags-server",
|
||||
"@kbn/core-plugins-server",
|
||||
"@kbn/config-schema",
|
||||
"@kbn/developer-examples-plugin",
|
||||
]
|
||||
}
|
13
package.json
13
package.json
|
@ -287,6 +287,12 @@
|
|||
"@kbn/core-execution-context-server-internal": "link:packages/core/execution-context/core-execution-context-server-internal",
|
||||
"@kbn/core-fatal-errors-browser": "link:packages/core/fatal-errors/core-fatal-errors-browser",
|
||||
"@kbn/core-fatal-errors-browser-internal": "link:packages/core/fatal-errors/core-fatal-errors-browser-internal",
|
||||
"@kbn/core-feature-flags-browser": "link:packages/core/feature-flags/core-feature-flags-browser",
|
||||
"@kbn/core-feature-flags-browser-internal": "link:packages/core/feature-flags/core-feature-flags-browser-internal",
|
||||
"@kbn/core-feature-flags-browser-mocks": "link:packages/core/feature-flags/core-feature-flags-browser-mocks",
|
||||
"@kbn/core-feature-flags-server": "link:packages/core/feature-flags/core-feature-flags-server",
|
||||
"@kbn/core-feature-flags-server-internal": "link:packages/core/feature-flags/core-feature-flags-server-internal",
|
||||
"@kbn/core-feature-flags-server-mocks": "link:packages/core/feature-flags/core-feature-flags-server-mocks",
|
||||
"@kbn/core-history-block-plugin": "link:test/plugin_functional/plugins/core_history_block",
|
||||
"@kbn/core-http-browser": "link:packages/core/http/core-http-browser",
|
||||
"@kbn/core-http-browser-internal": "link:packages/core/http/core-http-browser-internal",
|
||||
|
@ -505,6 +511,7 @@
|
|||
"@kbn/expressions-explorer-plugin": "link:examples/expressions_explorer",
|
||||
"@kbn/expressions-plugin": "link:src/plugins/expressions",
|
||||
"@kbn/feature-controls-examples-plugin": "link:examples/feature_control_examples",
|
||||
"@kbn/feature-flags-example-plugin": "link:examples/feature_flags_example",
|
||||
"@kbn/feature-usage-test-plugin": "link:x-pack/test/plugin_api_integration/plugins/feature_usage_test",
|
||||
"@kbn/features-plugin": "link:x-pack/plugins/features",
|
||||
"@kbn/fec-alerts-test-plugin": "link:x-pack/test/functional_execution_context/plugins/alerts",
|
||||
|
@ -988,6 +995,7 @@
|
|||
"@langchain/openai": "^0.1.3",
|
||||
"@langtrase/trace-attributes": "^3.0.8",
|
||||
"@launchdarkly/node-server-sdk": "^9.5.4",
|
||||
"@launchdarkly/openfeature-node-server": "^1.0.0",
|
||||
"@loaders.gl/core": "^3.4.7",
|
||||
"@loaders.gl/json": "^3.4.7",
|
||||
"@loaders.gl/shapefile": "^3.4.7",
|
||||
|
@ -996,6 +1004,10 @@
|
|||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||
"@mapbox/mapbox-gl-supported": "2.0.1",
|
||||
"@mapbox/vector-tile": "1.3.1",
|
||||
"@openfeature/core": "^1.3.0",
|
||||
"@openfeature/launchdarkly-client-provider": "^0.3.0",
|
||||
"@openfeature/server-sdk": "^1.15.0",
|
||||
"@openfeature/web-sdk": "^1.2.1",
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"@opentelemetry/api-metrics": "^0.31.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.34.0",
|
||||
|
@ -1129,7 +1141,6 @@
|
|||
"langchain": "^0.2.11",
|
||||
"langsmith": "^0.1.39",
|
||||
"launchdarkly-js-client-sdk": "^3.4.0",
|
||||
"launchdarkly-node-server-sdk": "^7.0.3",
|
||||
"load-json-file": "^6.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^4.1.5",
|
||||
|
|
|
@ -15,7 +15,7 @@ import type {
|
|||
import type { InternalElasticsearchServiceStart } from './types';
|
||||
|
||||
/**
|
||||
* The {@link UiSettingsRequestHandlerContext} implementation.
|
||||
* The {@link ElasticsearchRequestHandlerContext} implementation.
|
||||
* @internal
|
||||
*/
|
||||
export class CoreElasticsearchRouteHandlerContext implements ElasticsearchRequestHandlerContext {
|
||||
|
|
158
packages/core/feature-flags/README.mdx
Normal file
158
packages/core/feature-flags/README.mdx
Normal file
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
id: kibFeatureFlagsService
|
||||
slug: /kibana-dev-docs/tutorials/feature-flags-service
|
||||
title: Feature Flags service
|
||||
description: The Feature Flags service provides the necessary APIs to evaluate dynamic feature flags.
|
||||
date: 2024-07-26
|
||||
tags: ['kibana', 'dev', 'contributor', 'api docs', 'a/b testing', 'feature flags', 'flags']
|
||||
---
|
||||
|
||||
# Feature Flags Service
|
||||
|
||||
The Feature Flags service provides the necessary APIs to evaluate dynamic feature flags.
|
||||
|
||||
The service is always enabled, however, it will return the fallback value if a feature flags provider hasn't been attached.
|
||||
Kibana only registers a provider when running on Elastic Cloud Hosted/Serverless.
|
||||
|
||||
For a code example, refer to the [Feature Flags Example plugin](../../../examples/feature_flags_example)
|
||||
|
||||
## Registering a feature flag
|
||||
|
||||
Kibana follows a _gitops_ approach when managing feature flags. To declare a feature flag, add your flags definitions in
|
||||
your plugin's `server/index.ts` file:
|
||||
|
||||
```typescript
|
||||
// <plugin>/server/index.ts
|
||||
import type { FeatureFlagDefinitions } from '@kbn/core-feature-flags-server';
|
||||
import type { PluginInitializerContext } from '@kbn/core-plugins-server';
|
||||
|
||||
export const featureFlags: FeatureFlagDefinitions = [
|
||||
{
|
||||
key: 'my-cool-feature',
|
||||
name: 'My cool feature',
|
||||
description: 'Enables the cool feature to auto-hide the navigation bar',
|
||||
tags: ['my-plugin', 'my-service', 'ui'],
|
||||
variationType: 'boolean',
|
||||
variations: [
|
||||
{
|
||||
name: 'On',
|
||||
description: 'Auto-hides the bar',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
name: 'Off',
|
||||
description: 'Static always-on',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{...},
|
||||
];
|
||||
|
||||
export async function plugin(initializerContext: PluginInitializerContext) {
|
||||
const { FeatureFlagsExamplePlugin } = await import('./plugin');
|
||||
return new FeatureFlagsExamplePlugin(initializerContext);
|
||||
}
|
||||
```
|
||||
|
||||
After merging your PR, the CI will create/update the flags in our third-party feature flags provider.
|
||||
|
||||
### Deprecation/removal strategy
|
||||
|
||||
When your code doesn't use the feature flag anymore, it is recommended to clean up the feature flags when possible.
|
||||
There are a few considerations to take into account when performing this clean-up:
|
||||
|
||||
1. Always deprecate first, remove after
|
||||
2. When to remove?
|
||||
|
||||
#### Always deprecate first, remove after
|
||||
|
||||
Just because the CI syncs the state of `main` to our feature flag provider, there is a high probability that the
|
||||
previous version of the code that still relied on the feature flag is still running out there.
|
||||
|
||||
For that reason, the recommendation is to always deprecate before removing the flags. This will keep evaluating the flags,
|
||||
according to the segmentation rules configured for the flag.
|
||||
|
||||
#### When to remove?
|
||||
|
||||
After deprecation, we need to consider when it's safe to remove the flag. There are different scenarios that come with
|
||||
different recommendations:
|
||||
|
||||
* The segmentation rules of my flag are set up to return the fallback value 100% of the time: it should be safe to
|
||||
remove the flag at any time.
|
||||
* My flag only made it to Serverless (it never made it to Elastic Cloud Hosted): it should be safe to remove the flag
|
||||
after 2 releases have been rolled out (roughly 2-3 weeks later). This is to ensure that all Serverless projects have
|
||||
been upgraded and that we won't need to rollback to the previous version.
|
||||
* My flag made it to Elastic Cloud Hosted: if we want to remove the flag, we should approach the affected customers to
|
||||
fix the expected values via [config overrides](#config-overrides).
|
||||
|
||||
In general, the recommendation is to check our telemetry to validate the usage of our flags.
|
||||
|
||||
## Evaluating feature flags
|
||||
|
||||
This service provides 2 ways to evaluate your feature flags, depending on the use case:
|
||||
|
||||
1. **Single evaluation**: performs the evaluation once, and doesn't react to updates. These APIs are synchronous in the
|
||||
browser, and asynchronous in the server.
|
||||
2. **Observed evaluation**: observes the flag for any changes so that the code can adapt. These APIs return an RxJS observable.
|
||||
|
||||
Also, the APIs are typed, so you need to use the appropriate API depending on the `variationType` you defined your flag:
|
||||
|
||||
| Type | Single evaluation | Observed evaluation |
|
||||
|:-------:|:--------------------------------------------------------|:---------------------------------------------------------|
|
||||
| Boolean | `core.featureFlags.getBooleanValue(flagName, fallback)` | `core.featureFlags.getBooleanValue$(flagName, fallback)` |
|
||||
| String | `core.featureFlags.getStringValue(flagName, fallback)` | `core.featureFlags.getStringValue$(flagName, fallback)` |
|
||||
| Number | `core.featureFlags.getNumberValue(flagName, fallback)` | `core.featureFlags.getNumberValue$(flagName, fallback)` |
|
||||
|
||||
### Request handler context
|
||||
|
||||
Additionally, to make things easier in our HTTP handlers, the _Single evaluation_ APIs are available as part of the core
|
||||
context provided to the handlers:
|
||||
|
||||
```typescript
|
||||
async (context, request, response) => {
|
||||
const { featureFlags } = await context.core;
|
||||
return response.ok({
|
||||
body: {
|
||||
number: await featureFlags.getNumberValue('example-number', 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Extending the evaluation context
|
||||
|
||||
The <DocLink id="kibCloudExperimentsPlugin" section="evaluation-context" text="current evaluation context"/> should have
|
||||
enough information to declare the segmentation rules for your feature flags. However, if your use case requires additional
|
||||
context, feel free to call the API `core.featureFlags.setContext()` from your plugin.
|
||||
|
||||
At the moment, we use 2 levels of context: `kibana` and `organization` that we can use for segmentation purposes at
|
||||
different levels. By default, the API appends the context to the `kibana` scope. If you need to extend the `organization`
|
||||
scope, make sure to add `kind: 'organization'` to the object provided to the `setContext` API.
|
||||
|
||||
## Config overrides
|
||||
|
||||
To help with testing, and to provide an escape hatch in cases where the flag evaluation is not behaving as intended,
|
||||
the Feature Flags Service provides a way to force the values of a feature flag without attempting to resolve it via the
|
||||
provider. In the `kibana.yml`, the following config sets the overrides:
|
||||
|
||||
```yaml
|
||||
feature_flags.overrides:
|
||||
my-feature-flag: 'my-forced-value'
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> There is no validation regarding the variations nor the type of the flags. Use these overrides with caution.
|
||||
|
||||
### Dynamic config
|
||||
|
||||
When running in our test environments, the overrides can be updated without restarting Kibana via the HTTP `PUT /internal/core/_settings`:
|
||||
|
||||
```
|
||||
PUT /internal/core/_settings
|
||||
{
|
||||
"feature_flags.overrides": {
|
||||
"my-feature-flag": "my-forced-value"
|
||||
}
|
||||
}
|
||||
```
|
|
@ -0,0 +1,5 @@
|
|||
# @kbn/core-feature-flags-browser-internal
|
||||
|
||||
Internal implementation of the browser-side Feature Flags Service.
|
||||
|
||||
It should only be imported by _Core_ packages.
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { FeatureFlagsService, type FeatureFlagsSetupDeps } from './src/feature_flags_service';
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../..',
|
||||
roots: ['<rootDir>/packages/core/feature-flags/core-feature-flags-browser-internal'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/core-feature-flags-browser-internal",
|
||||
"owner": "@elastic/kibana-core"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/core-feature-flags-browser-internal",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
|
||||
}
|
|
@ -0,0 +1,292 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { apm } from '@elastic/apm-rum';
|
||||
import { type Client, OpenFeature, type Provider } from '@openfeature/web-sdk';
|
||||
import { coreContextMock } from '@kbn/core-base-browser-mocks';
|
||||
import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser';
|
||||
import { injectedMetadataServiceMock } from '@kbn/core-injected-metadata-browser-mocks';
|
||||
import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal';
|
||||
import { FeatureFlagsService } from '..';
|
||||
|
||||
async function isSettledPromise(p: Promise<unknown>) {
|
||||
const immediateValue = {};
|
||||
const result = await Promise.race([p, immediateValue]);
|
||||
return result !== immediateValue;
|
||||
}
|
||||
|
||||
describe('FeatureFlagsService Browser', () => {
|
||||
let featureFlagsService: FeatureFlagsService;
|
||||
let featureFlagsClient: Client;
|
||||
let injectedMetadata: jest.Mocked<InternalInjectedMetadataSetup>;
|
||||
|
||||
beforeEach(() => {
|
||||
const getClientSpy = jest.spyOn(OpenFeature, 'getClient');
|
||||
featureFlagsService = new FeatureFlagsService(coreContextMock.create());
|
||||
featureFlagsClient = getClientSpy.mock.results[0].value;
|
||||
injectedMetadata = injectedMetadataServiceMock.createSetupContract();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await featureFlagsService.stop();
|
||||
jest.clearAllMocks();
|
||||
await OpenFeature.clearProviders();
|
||||
await OpenFeature.clearContexts();
|
||||
});
|
||||
|
||||
describe('provider handling', () => {
|
||||
test('appends a provider (without awaiting)', () => {
|
||||
expect.assertions(1);
|
||||
const { setProvider } = featureFlagsService.setup({ injectedMetadata });
|
||||
const spy = jest.spyOn(OpenFeature, 'setProviderAndWait');
|
||||
const fakeProvider = { metadata: { name: 'fake provider' } } as Provider;
|
||||
setProvider(fakeProvider);
|
||||
expect(spy).toHaveBeenCalledWith(fakeProvider);
|
||||
});
|
||||
|
||||
test('throws an error if called twice', () => {
|
||||
const { setProvider } = featureFlagsService.setup({ injectedMetadata });
|
||||
const fakeProvider = { metadata: { name: 'fake provider' } } as Provider;
|
||||
setProvider(fakeProvider);
|
||||
expect(() => setProvider(fakeProvider)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"A provider has already been set. This API cannot be called twice."`
|
||||
);
|
||||
});
|
||||
|
||||
test('awaits initialization in the start context', async () => {
|
||||
const { setProvider } = featureFlagsService.setup({ injectedMetadata });
|
||||
let externalResolve: Function = () => void 0;
|
||||
const spy = jest.spyOn(OpenFeature, 'setProviderAndWait').mockImplementation(async () => {
|
||||
await new Promise((resolve) => {
|
||||
externalResolve = resolve;
|
||||
});
|
||||
});
|
||||
const fakeProvider = {} as Provider;
|
||||
setProvider(fakeProvider);
|
||||
expect(spy).toHaveBeenCalledWith(fakeProvider);
|
||||
const startPromise = featureFlagsService.start();
|
||||
await expect(isSettledPromise(startPromise)).resolves.toBe(false);
|
||||
externalResolve();
|
||||
await new Promise((resolve) => process.nextTick(resolve)); // Wait for the promise resolution to spread
|
||||
await expect(isSettledPromise(startPromise)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('do not hold for too long during initialization', async () => {
|
||||
const { setProvider } = featureFlagsService.setup({ injectedMetadata });
|
||||
const spy = jest.spyOn(OpenFeature, 'setProviderAndWait').mockImplementation(async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
});
|
||||
const apmCaptureErrorSpy = jest.spyOn(apm, 'captureError');
|
||||
const fakeProvider = {} as Provider;
|
||||
setProvider(fakeProvider);
|
||||
expect(spy).toHaveBeenCalledWith(fakeProvider);
|
||||
const startPromise = featureFlagsService.start();
|
||||
await expect(isSettledPromise(startPromise)).resolves.toBe(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100)); // A bit longer than 2 seconds
|
||||
await expect(isSettledPromise(startPromise)).resolves.toBe(true);
|
||||
expect(apmCaptureErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('The feature flags provider took too long to initialize.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context handling', () => {
|
||||
let setContextSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
setContextSpy = jest.spyOn(OpenFeature, 'setContext');
|
||||
});
|
||||
|
||||
test('appends context to the provider', async () => {
|
||||
const { appendContext } = featureFlagsService.setup({ injectedMetadata });
|
||||
await appendContext({ kind: 'multi' });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi' });
|
||||
});
|
||||
|
||||
test('appends context to the provider (start method)', async () => {
|
||||
featureFlagsService.setup({ injectedMetadata });
|
||||
const { appendContext } = await featureFlagsService.start();
|
||||
await appendContext({ kind: 'multi' });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi' });
|
||||
});
|
||||
|
||||
test('full multi context pass-through', async () => {
|
||||
const { appendContext } = featureFlagsService.setup({ injectedMetadata });
|
||||
const context = {
|
||||
kind: 'multi' as const,
|
||||
kibana: {
|
||||
key: 'kibana-1',
|
||||
},
|
||||
organization: {
|
||||
key: 'organization-1',
|
||||
},
|
||||
};
|
||||
await appendContext(context);
|
||||
expect(setContextSpy).toHaveBeenCalledWith(context);
|
||||
});
|
||||
|
||||
test('appends to the existing context', async () => {
|
||||
const { appendContext } = featureFlagsService.setup({ injectedMetadata });
|
||||
const initialContext = {
|
||||
kind: 'multi' as const,
|
||||
kibana: {
|
||||
key: 'kibana-1',
|
||||
},
|
||||
organization: {
|
||||
key: 'organization-1',
|
||||
},
|
||||
};
|
||||
await appendContext(initialContext);
|
||||
expect(setContextSpy).toHaveBeenCalledWith(initialContext);
|
||||
|
||||
await appendContext({ kind: 'multi', kibana: { has_data: true } });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({
|
||||
...initialContext,
|
||||
kibana: {
|
||||
...initialContext.kibana,
|
||||
has_data: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('converts single-contexts to multi-context', async () => {
|
||||
const { appendContext } = featureFlagsService.setup({ injectedMetadata });
|
||||
await appendContext({ kind: 'organization', key: 'organization-1' });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({
|
||||
kind: 'multi',
|
||||
organization: {
|
||||
key: 'organization-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('if no `kind` provided, it defaults to the kibana context', async () => {
|
||||
const { appendContext } = featureFlagsService.setup({ injectedMetadata });
|
||||
await appendContext({ key: 'key-1', has_data: false });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({
|
||||
kind: 'multi',
|
||||
kibana: {
|
||||
key: 'key-1',
|
||||
has_data: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('flag evaluation', () => {
|
||||
let startContract: FeatureFlagsStart;
|
||||
let apmSpy: jest.SpyInstance;
|
||||
let addHandlerSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
addHandlerSpy = jest.spyOn(featureFlagsClient, 'addHandler');
|
||||
injectedMetadata.getFeatureFlags.mockReturnValue({
|
||||
overrides: { 'my-overridden-flag': true },
|
||||
});
|
||||
featureFlagsService.setup({ injectedMetadata });
|
||||
startContract = await featureFlagsService.start();
|
||||
apmSpy = jest.spyOn(apm, 'addLabels');
|
||||
});
|
||||
|
||||
// We don't need to test the client, just our APIs, so testing that it returns the fallback value should be enough.
|
||||
test('get boolean flag', () => {
|
||||
const value = false;
|
||||
expect(startContract.getBooleanValue('my-flag', value)).toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
});
|
||||
|
||||
test('get string flag', () => {
|
||||
const value = 'my-default';
|
||||
expect(startContract.getStringValue('my-flag', value)).toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
});
|
||||
|
||||
test('get number flag', () => {
|
||||
const value = 42;
|
||||
expect(startContract.getNumberValue('my-flag', value)).toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
});
|
||||
|
||||
test('observe a boolean flag', async () => {
|
||||
const value = false;
|
||||
const flag$ = startContract.getBooleanValue$('my-flag', value);
|
||||
const observedValues: boolean[] = [];
|
||||
flag$.subscribe((v) => observedValues.push(v));
|
||||
// Initial emission
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
expect(observedValues).toHaveLength(1);
|
||||
|
||||
// Does not reevaluate and emit if the other flags are changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(1); // still 1
|
||||
|
||||
// Reevaluates and emits when the observed flag is changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('observe a string flag', async () => {
|
||||
const value = 'my-value';
|
||||
const flag$ = startContract.getStringValue$('my-flag', value);
|
||||
const observedValues: string[] = [];
|
||||
flag$.subscribe((v) => observedValues.push(v));
|
||||
// Initial emission
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
expect(observedValues).toHaveLength(1);
|
||||
|
||||
// Does not reevaluate and emit if the other flags are changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(1); // still 1
|
||||
|
||||
// Reevaluates and emits when the observed flag is changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('observe a number flag', async () => {
|
||||
const value = 42;
|
||||
const flag$ = startContract.getNumberValue$('my-flag', value);
|
||||
const observedValues: number[] = [];
|
||||
flag$.subscribe((v) => observedValues.push(v));
|
||||
// Initial emission
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
expect(observedValues).toHaveLength(1);
|
||||
|
||||
// Does not reevaluate and emit if the other flags are changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(1); // still 1
|
||||
|
||||
// Reevaluates and emits when the observed flag is changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('with overrides', async () => {
|
||||
const getBooleanValueSpy = jest.spyOn(featureFlagsClient, 'getBooleanValue');
|
||||
expect(startContract.getBooleanValue('my-overridden-flag', false)).toEqual(true);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-overridden-flag': true });
|
||||
expect(getBooleanValueSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Only to prove the spy works
|
||||
expect(startContract.getBooleanValue('another-flag', false)).toEqual(false);
|
||||
expect(getBooleanValueSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getBooleanValueSpy).toHaveBeenCalledWith('another-flag', false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { CoreContext } from '@kbn/core-base-browser-internal';
|
||||
import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal';
|
||||
import type { Logger } from '@kbn/logging';
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FeatureFlagsSetup,
|
||||
FeatureFlagsStart,
|
||||
MultiContextEvaluationContext,
|
||||
} from '@kbn/core-feature-flags-browser';
|
||||
import { apm } from '@elastic/apm-rum';
|
||||
import { type Client, ClientProviderEvents, OpenFeature } from '@openfeature/web-sdk';
|
||||
import deepMerge from 'deepmerge';
|
||||
import { filter, map, startWith, Subject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* setup method dependencies
|
||||
* @private
|
||||
*/
|
||||
export interface FeatureFlagsSetupDeps {
|
||||
/**
|
||||
* Used to read the flag overrides set up in the configuration file.
|
||||
*/
|
||||
injectedMetadata: InternalInjectedMetadataSetup;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser-side Feature Flags Service
|
||||
* @private
|
||||
*/
|
||||
export class FeatureFlagsService {
|
||||
private readonly featureFlagsClient: Client;
|
||||
private readonly logger: Logger;
|
||||
private isProviderReadyPromise?: Promise<void>;
|
||||
private context: MultiContextEvaluationContext = { kind: 'multi' };
|
||||
private overrides: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* The core service's constructor
|
||||
* @param core {@link CoreContext}
|
||||
*/
|
||||
constructor(core: CoreContext) {
|
||||
this.logger = core.logger.get('feature-flags-service');
|
||||
this.featureFlagsClient = OpenFeature.getClient();
|
||||
OpenFeature.setLogger(this.logger.get('open-feature'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup lifecycle method
|
||||
* @param deps {@link FeatureFlagsSetup} including the {@link InternalInjectedMetadataSetup} used to retrieve the feature flags.
|
||||
*/
|
||||
public setup(deps: FeatureFlagsSetupDeps): FeatureFlagsSetup {
|
||||
const featureFlagsInjectedMetadata = deps.injectedMetadata.getFeatureFlags();
|
||||
if (featureFlagsInjectedMetadata) {
|
||||
this.overrides = featureFlagsInjectedMetadata.overrides;
|
||||
}
|
||||
return {
|
||||
setProvider: (provider) => {
|
||||
if (this.isProviderReadyPromise) {
|
||||
throw new Error('A provider has already been set. This API cannot be called twice.');
|
||||
}
|
||||
this.isProviderReadyPromise = OpenFeature.setProviderAndWait(provider);
|
||||
},
|
||||
appendContext: (contextToAppend) => this.appendContext(contextToAppend),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start lifecycle method
|
||||
*/
|
||||
public async start(): Promise<FeatureFlagsStart> {
|
||||
const featureFlagsChanged$ = new Subject<string[]>();
|
||||
this.featureFlagsClient.addHandler(ClientProviderEvents.ConfigurationChanged, (event) => {
|
||||
if (event?.flagsChanged) {
|
||||
featureFlagsChanged$.next(event.flagsChanged);
|
||||
}
|
||||
});
|
||||
const observeFeatureFlag$ = (flagName: string) =>
|
||||
featureFlagsChanged$.pipe(
|
||||
filter((flagNames) => flagNames.includes(flagName)),
|
||||
startWith([flagName]) // only to emit on the first call
|
||||
);
|
||||
|
||||
await this.waitForProviderInitialization();
|
||||
|
||||
return {
|
||||
appendContext: (contextToAppend) => this.appendContext(contextToAppend),
|
||||
getBooleanValue: (flagName: string, fallbackValue: boolean) =>
|
||||
this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue),
|
||||
getStringValue: <Value extends string>(flagName: string, fallbackValue: Value) =>
|
||||
this.evaluateFlag<Value>(this.featureFlagsClient.getStringValue, flagName, fallbackValue),
|
||||
getNumberValue: <Value extends number>(flagName: string, fallbackValue: Value) =>
|
||||
this.evaluateFlag<Value>(this.featureFlagsClient.getNumberValue, flagName, fallbackValue),
|
||||
getBooleanValue$: (flagName, fallbackValue) => {
|
||||
return observeFeatureFlag$(flagName).pipe(
|
||||
map(() =>
|
||||
this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue)
|
||||
)
|
||||
);
|
||||
},
|
||||
getStringValue$: <Value extends string>(flagName: string, fallbackValue: Value) => {
|
||||
return observeFeatureFlag$(flagName).pipe(
|
||||
map(() =>
|
||||
this.evaluateFlag<Value>(
|
||||
this.featureFlagsClient.getStringValue,
|
||||
flagName,
|
||||
fallbackValue
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
getNumberValue$: <Value extends number>(flagName: string, fallbackValue: Value) => {
|
||||
return observeFeatureFlag$(flagName).pipe(
|
||||
map(() =>
|
||||
this.evaluateFlag<Value>(
|
||||
this.featureFlagsClient.getNumberValue,
|
||||
flagName,
|
||||
fallbackValue
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop lifecycle method
|
||||
*/
|
||||
public async stop() {
|
||||
await OpenFeature.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the provider initialization with a timeout to avoid holding the page load for too long
|
||||
* @private
|
||||
*/
|
||||
private async waitForProviderInitialization() {
|
||||
// Adding a timeout here to avoid hanging the start for too long if the provider is unresponsive
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
this.isProviderReadyPromise,
|
||||
new Promise((resolve) => {
|
||||
timeoutId = setTimeout(resolve, 2 * 1000);
|
||||
}).then(() => {
|
||||
const msg = `The feature flags provider took too long to initialize.
|
||||
Won't hold the page load any longer.
|
||||
Feature flags will return the provided fallbacks until the provider is eventually initialized.`;
|
||||
this.logger.warn(msg);
|
||||
apm.captureError(msg);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper to evaluate flags with the common config overrides interceptions + APM and counters reporting
|
||||
* @param evaluationFn The actual evaluation API
|
||||
* @param flagName The name of the flag to evaluate
|
||||
* @param fallbackValue The fallback value
|
||||
* @private
|
||||
*/
|
||||
private evaluateFlag<T extends string | boolean | number>(
|
||||
evaluationFn: (flagName: string, fallbackValue: T) => T,
|
||||
flagName: string,
|
||||
fallbackValue: T
|
||||
): T {
|
||||
const value =
|
||||
typeof this.overrides[flagName] !== 'undefined'
|
||||
? (this.overrides[flagName] as T)
|
||||
: // We have to bind the evaluation or the client will lose its internal context
|
||||
evaluationFn.bind(this.featureFlagsClient)(flagName, fallbackValue);
|
||||
apm.addLabels({ [`flag_${flagName}`]: value });
|
||||
// TODO: increment usage counter
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the provided context to fulfill the expected multi-context structure.
|
||||
* @param contextToAppend The {@link EvaluationContext} to append.
|
||||
* @private
|
||||
*/
|
||||
private async appendContext(contextToAppend: EvaluationContext): Promise<void> {
|
||||
// If no kind provided, default to the project|deployment level.
|
||||
const { kind = 'kibana', ...rest } = contextToAppend;
|
||||
// Format the context to fulfill the expected multi-context structure
|
||||
const formattedContextToAppend: MultiContextEvaluationContext =
|
||||
kind === 'multi'
|
||||
? (contextToAppend as MultiContextEvaluationContext)
|
||||
: { kind: 'multi', [kind]: rest };
|
||||
|
||||
// Merge the formatted context to append to the global context, and set it in the OpenFeature client.
|
||||
this.context = deepMerge(this.context, formattedContextToAppend);
|
||||
await OpenFeature.setContext(this.context);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node",
|
||||
"react"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core-base-browser-internal",
|
||||
"@kbn/core-feature-flags-browser",
|
||||
"@kbn/logging",
|
||||
"@kbn/core-base-browser-mocks",
|
||||
"@kbn/core-injected-metadata-browser-internal",
|
||||
"@kbn/core-injected-metadata-browser-mocks",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/core-feature-flags-browser-mocks
|
||||
|
||||
Browser-side Jest mocks for the Feature Flags Service.
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { FeatureFlagsSetup, FeatureFlagsStart } from '@kbn/core-feature-flags-browser';
|
||||
import type { FeatureFlagsService } from '@kbn/core-feature-flags-browser-internal';
|
||||
import type { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
const createFeatureFlagsSetup = (): jest.Mocked<FeatureFlagsSetup> => {
|
||||
return {
|
||||
setProvider: jest.fn(),
|
||||
appendContext: jest.fn().mockImplementation(Promise.resolve),
|
||||
};
|
||||
};
|
||||
|
||||
const createFeatureFlagsStart = (): jest.Mocked<FeatureFlagsStart> => {
|
||||
return {
|
||||
appendContext: jest.fn().mockImplementation(Promise.resolve),
|
||||
getBooleanValue: jest.fn().mockImplementation(async (_, fallback) => fallback),
|
||||
getNumberValue: jest.fn().mockImplementation(async (_, fallback) => fallback),
|
||||
getStringValue: jest.fn().mockImplementation(async (_, fallback) => fallback),
|
||||
getBooleanValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)),
|
||||
getStringValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)),
|
||||
getNumberValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)),
|
||||
};
|
||||
};
|
||||
|
||||
const createFeatureFlagsServiceMock = (): jest.Mocked<PublicMethodsOf<FeatureFlagsService>> => {
|
||||
return {
|
||||
setup: jest.fn().mockImplementation(createFeatureFlagsSetup),
|
||||
start: jest.fn().mockImplementation(async () => createFeatureFlagsStart()),
|
||||
stop: jest.fn().mockImplementation(Promise.resolve),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Mocks for the Feature Flags service (browser-side)
|
||||
*/
|
||||
export const coreFeatureFlagsMock = {
|
||||
/**
|
||||
* Mocks the entire feature flags service
|
||||
*/
|
||||
create: createFeatureFlagsServiceMock,
|
||||
/**
|
||||
* Mocks the setup contract
|
||||
*/
|
||||
createSetup: createFeatureFlagsSetup,
|
||||
/**
|
||||
* Mocks the start contract
|
||||
*/
|
||||
createStart: createFeatureFlagsStart,
|
||||
};
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../..',
|
||||
roots: ['<rootDir>/packages/core/feature-flags/core-feature-flags-browser-mocks'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/core-feature-flags-browser-mocks",
|
||||
"owner": "@elastic/kibana-core"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/core-feature-flags-browser-mocks",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node",
|
||||
"react"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core-feature-flags-browser",
|
||||
"@kbn/core-feature-flags-browser-internal",
|
||||
"@kbn/utility-types",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/core-feature-flags-browser
|
||||
|
||||
Browser-side type definitions for the Feature Flags Service.
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export type {
|
||||
EvaluationContext,
|
||||
MultiContextEvaluationContext,
|
||||
SingleContextEvaluationContext,
|
||||
FeatureFlagsSetup,
|
||||
FeatureFlagsStart,
|
||||
} from './src/types';
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/core-feature-flags-browser",
|
||||
"owner": "@elastic/kibana-core"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/core-feature-flags-browser",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
|
||||
}
|
|
@ -0,0 +1,165 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { Provider } from '@openfeature/web-sdk';
|
||||
import { type EvaluationContext as OpenFeatureEvaluationContext } from '@openfeature/core';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* The evaluation context to use when retrieving the flags.
|
||||
*
|
||||
* We use multi-context so that we can apply segmentation rules at different levels (`organization`/`kibana`).
|
||||
* * `organization` includes any information that is common to all the projects/deployments in an organization. An example is the in_trial status.
|
||||
* * The `kibana` context includes all the information that identifies a project/deployment. Examples are version, offering, and has_data.
|
||||
* Kind helps us specify which sub-context should receive the new attributes.
|
||||
* If no `kind` is provided, it defaults to `kibana`.
|
||||
*
|
||||
* @example Providing properties for both contexts
|
||||
* {
|
||||
* kind: 'multi',
|
||||
* organization: {
|
||||
* key: 1234,
|
||||
* in_trial: true,
|
||||
* },
|
||||
* kibana: {
|
||||
* key: 12345567890,
|
||||
* version: 8.15.0,
|
||||
* buildHash: 'ffffffffaaaaaaaa',
|
||||
* },
|
||||
* }
|
||||
*
|
||||
* @example Appending context to the organization sub-context
|
||||
* {
|
||||
* kind: 'organization',
|
||||
* key: 1234,
|
||||
* in_trial: true,
|
||||
* }
|
||||
*
|
||||
* @example Appending context to the `kibana` sub-context
|
||||
* {
|
||||
* key: 12345567890,
|
||||
* version: 8.15.0,
|
||||
* buildHash: 'ffffffffaaaaaaaa',
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type EvaluationContext = MultiContextEvaluationContext | SingleContextEvaluationContext;
|
||||
|
||||
/**
|
||||
* Multi-context format. The sub-contexts are provided in their nested properties.
|
||||
* @public
|
||||
*/
|
||||
export type MultiContextEvaluationContext = OpenFeatureEvaluationContext & {
|
||||
/**
|
||||
* Static `multi` string
|
||||
*/
|
||||
kind: 'multi';
|
||||
/**
|
||||
* The Elastic Cloud organization-specific context.
|
||||
*/
|
||||
organization?: OpenFeatureEvaluationContext;
|
||||
/**
|
||||
* The deployment/project-specific context.
|
||||
*/
|
||||
kibana?: OpenFeatureEvaluationContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Single Context format. If `kind` is not specified, it applies to the `kibana` sub-context.
|
||||
*/
|
||||
export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & {
|
||||
/**
|
||||
* The sub-context that it's updated. Defaults to `kibana`.
|
||||
*/
|
||||
kind?: 'organization' | 'kibana';
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup contract of the Feature Flags Service
|
||||
* @public
|
||||
*/
|
||||
export interface FeatureFlagsSetup {
|
||||
/**
|
||||
* Registers an OpenFeature provider to talk to the
|
||||
* 3rd-party service that manages the Feature Flags.
|
||||
* @param provider The {@link Provider | OpenFeature Provider} to handle the communication with the feature flags management system.
|
||||
* @public
|
||||
*/
|
||||
setProvider(provider: Provider): void;
|
||||
|
||||
/**
|
||||
* Appends new keys to the evaluation context.
|
||||
* @param contextToAppend The additional keys that should be appended/modified in the evaluation context.
|
||||
* @public
|
||||
*/
|
||||
appendContext(contextToAppend: EvaluationContext): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup contract of the Feature Flags Service
|
||||
* @public
|
||||
*/
|
||||
export interface FeatureFlagsStart {
|
||||
/**
|
||||
* Appends new keys to the evaluation context.
|
||||
* @param contextToAppend The additional keys that should be appended/modified in the evaluation context.
|
||||
* @public
|
||||
*/
|
||||
appendContext(contextToAppend: EvaluationContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Evaluates a boolean flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getBooleanValue(flagName: string, fallbackValue: boolean): boolean;
|
||||
|
||||
/**
|
||||
* Evaluates a string flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getStringValue<Value extends string>(flagName: string, fallbackValue: Value): Value;
|
||||
|
||||
/**
|
||||
* Evaluates a number flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getNumberValue<Value extends number>(flagName: string, fallbackValue: Value): Value;
|
||||
|
||||
/**
|
||||
* Returns an observable of a boolean flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getBooleanValue$(flagName: string, fallbackValue: boolean): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Returns an observable of a string flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getStringValue$<Value extends string>(flagName: string, fallbackValue: Value): Observable<Value>;
|
||||
|
||||
/**
|
||||
* Returns an observable of a number flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getNumberValue$<Value extends number>(flagName: string, fallbackValue: Value): Observable<Value>;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"node",
|
||||
"react"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": []
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
# @kbn/core-feature-flags-server-internal
|
||||
|
||||
Internal implementation of the server-side Feature Flags Service.
|
||||
|
||||
It should only be imported by _Core_ packages.
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { featureFlagsConfig } from './src/feature_flags_config';
|
||||
export { FeatureFlagsService, type InternalFeatureFlagsSetup } from './src/feature_flags_service';
|
||||
export { CoreFeatureFlagsRouteHandlerContext } from './src/feature_flags_request_handler_context';
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test/jest_node',
|
||||
rootDir: '../../../..',
|
||||
roots: ['<rootDir>/packages/core/feature-flags/core-feature-flags-server-internal'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-server",
|
||||
"id": "@kbn/core-feature-flags-server-internal",
|
||||
"owner": "@elastic/kibana-core"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/core-feature-flags-server-internal",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { ServiceConfigDescriptor } from '@kbn/core-base-server-internal';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
/**
|
||||
* The definition of the validation config schema
|
||||
* @private
|
||||
*/
|
||||
const configSchema = schema.object({
|
||||
overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definition of the Feature Flags configuration
|
||||
* @private
|
||||
*/
|
||||
export interface FeatureFlagsConfig {
|
||||
overrides?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config descriptor for the feature flags service
|
||||
* @private
|
||||
*/
|
||||
export const featureFlagsConfig: ServiceConfigDescriptor<FeatureFlagsConfig> = {
|
||||
/**
|
||||
* All config is prefixed by `feature_flags`
|
||||
*/
|
||||
path: 'feature_flags',
|
||||
/**
|
||||
* The definition of the validation config schema
|
||||
*/
|
||||
schema: configSchema,
|
||||
};
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type {
|
||||
FeatureFlagsRequestHandlerContext,
|
||||
FeatureFlagsStart,
|
||||
} from '@kbn/core-feature-flags-server';
|
||||
|
||||
/**
|
||||
* The {@link FeatureFlagsRequestHandlerContext} implementation.
|
||||
* @internal
|
||||
*/
|
||||
export class CoreFeatureFlagsRouteHandlerContext implements FeatureFlagsRequestHandlerContext {
|
||||
constructor(private readonly featureFlags: FeatureFlagsStart) {}
|
||||
|
||||
public getBooleanValue(flagName: string, fallback: boolean): Promise<boolean> {
|
||||
return this.featureFlags.getBooleanValue(flagName, fallback);
|
||||
}
|
||||
|
||||
public getStringValue<Value extends string>(flagName: string, fallback: Value): Promise<Value> {
|
||||
return this.featureFlags.getStringValue(flagName, fallback);
|
||||
}
|
||||
|
||||
public getNumberValue<Value extends number>(flagName: string, fallback: Value): Promise<Value> {
|
||||
return this.featureFlags.getNumberValue(flagName, fallback);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,260 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import apm from 'elastic-apm-node';
|
||||
import { type Client, OpenFeature, type Provider } from '@openfeature/server-sdk';
|
||||
import { mockCoreContext } from '@kbn/core-base-server-mocks';
|
||||
import { configServiceMock } from '@kbn/config-mocks';
|
||||
import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server';
|
||||
import { FeatureFlagsService } from '..';
|
||||
|
||||
describe('FeatureFlagsService Server', () => {
|
||||
let featureFlagsService: FeatureFlagsService;
|
||||
let featureFlagsClient: Client;
|
||||
|
||||
beforeEach(() => {
|
||||
const getClientSpy = jest.spyOn(OpenFeature, 'getClient');
|
||||
featureFlagsService = new FeatureFlagsService(
|
||||
mockCoreContext.create({
|
||||
configService: configServiceMock.create({
|
||||
atPath: {
|
||||
overrides: {
|
||||
'my-overridden-flag': true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
featureFlagsClient = getClientSpy.mock.results[0].value;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await featureFlagsService.stop();
|
||||
jest.clearAllMocks();
|
||||
await OpenFeature.clearProviders();
|
||||
});
|
||||
|
||||
describe('provider handling', () => {
|
||||
test('appends a provider (no async operation)', () => {
|
||||
expect.assertions(1);
|
||||
const { setProvider } = featureFlagsService.setup();
|
||||
const spy = jest.spyOn(OpenFeature, 'setProvider');
|
||||
const fakeProvider = { metadata: { name: 'fake provider' } } as Provider;
|
||||
setProvider(fakeProvider);
|
||||
expect(spy).toHaveBeenCalledWith(fakeProvider);
|
||||
});
|
||||
|
||||
test('throws an error if called twice', () => {
|
||||
const { setProvider } = featureFlagsService.setup();
|
||||
const fakeProvider = { metadata: { name: 'fake provider' } } as Provider;
|
||||
setProvider(fakeProvider);
|
||||
expect(() => setProvider(fakeProvider)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"A provider has already been set. This API cannot be called twice."`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context handling', () => {
|
||||
let setContextSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
setContextSpy = jest.spyOn(OpenFeature, 'setContext');
|
||||
});
|
||||
|
||||
test('appends context to the provider', () => {
|
||||
const { appendContext } = featureFlagsService.setup();
|
||||
appendContext({ kind: 'multi' });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi' });
|
||||
});
|
||||
|
||||
test('appends context to the provider (start method)', () => {
|
||||
featureFlagsService.setup();
|
||||
const { appendContext } = featureFlagsService.start();
|
||||
appendContext({ kind: 'multi' });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi' });
|
||||
});
|
||||
|
||||
test('full multi context pass-through', () => {
|
||||
const { appendContext } = featureFlagsService.setup();
|
||||
const context = {
|
||||
kind: 'multi' as const,
|
||||
kibana: {
|
||||
key: 'kibana-1',
|
||||
},
|
||||
organization: {
|
||||
key: 'organization-1',
|
||||
},
|
||||
};
|
||||
appendContext(context);
|
||||
expect(setContextSpy).toHaveBeenCalledWith(context);
|
||||
});
|
||||
|
||||
test('appends to the existing context', () => {
|
||||
const { appendContext } = featureFlagsService.setup();
|
||||
const initialContext = {
|
||||
kind: 'multi' as const,
|
||||
kibana: {
|
||||
key: 'kibana-1',
|
||||
},
|
||||
organization: {
|
||||
key: 'organization-1',
|
||||
},
|
||||
};
|
||||
appendContext(initialContext);
|
||||
expect(setContextSpy).toHaveBeenCalledWith(initialContext);
|
||||
|
||||
appendContext({ kind: 'multi', kibana: { has_data: true } });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({
|
||||
...initialContext,
|
||||
kibana: {
|
||||
...initialContext.kibana,
|
||||
has_data: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('converts single-contexts to multi-context', () => {
|
||||
const { appendContext } = featureFlagsService.setup();
|
||||
appendContext({ kind: 'organization', key: 'organization-1' });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({
|
||||
kind: 'multi',
|
||||
organization: {
|
||||
key: 'organization-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('if no `kind` provided, it defaults to the kibana context', () => {
|
||||
const { appendContext } = featureFlagsService.setup();
|
||||
appendContext({ key: 'key-1', has_data: false });
|
||||
expect(setContextSpy).toHaveBeenCalledWith({
|
||||
kind: 'multi',
|
||||
kibana: {
|
||||
key: 'key-1',
|
||||
has_data: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('flag evaluation', () => {
|
||||
let startContract: FeatureFlagsStart;
|
||||
let apmSpy: jest.SpyInstance;
|
||||
let addHandlerSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
addHandlerSpy = jest.spyOn(featureFlagsClient, 'addHandler');
|
||||
featureFlagsService.setup();
|
||||
startContract = featureFlagsService.start();
|
||||
apmSpy = jest.spyOn(apm, 'addLabels');
|
||||
});
|
||||
|
||||
// We don't need to test the client, just our APIs, so testing that it returns the fallback value should be enough.
|
||||
test('get boolean flag', async () => {
|
||||
const value = false;
|
||||
await expect(startContract.getBooleanValue('my-flag', value)).resolves.toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
});
|
||||
|
||||
test('get string flag', async () => {
|
||||
const value = 'my-default';
|
||||
await expect(startContract.getStringValue('my-flag', value)).resolves.toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
});
|
||||
|
||||
test('get number flag', async () => {
|
||||
const value = 42;
|
||||
await expect(startContract.getNumberValue('my-flag', value)).resolves.toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
});
|
||||
|
||||
test('observe a boolean flag', async () => {
|
||||
const value = false;
|
||||
const flag$ = startContract.getBooleanValue$('my-flag', value);
|
||||
const observedValues: boolean[] = [];
|
||||
flag$.subscribe((v) => observedValues.push(v));
|
||||
// Initial emission
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
expect(observedValues).toHaveLength(1);
|
||||
|
||||
// Does not reevaluate and emit if the other flags are changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(1); // still 1
|
||||
|
||||
// Reevaluates and emits when the observed flag is changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('observe a string flag', async () => {
|
||||
const value = 'my-value';
|
||||
const flag$ = startContract.getStringValue$('my-flag', value);
|
||||
const observedValues: string[] = [];
|
||||
flag$.subscribe((v) => observedValues.push(v));
|
||||
// Initial emission
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
expect(observedValues).toHaveLength(1);
|
||||
|
||||
// Does not reevaluate and emit if the other flags are changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(1); // still 1
|
||||
|
||||
// Reevaluates and emits when the observed flag is changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('observe a number flag', async () => {
|
||||
const value = 42;
|
||||
const flag$ = startContract.getNumberValue$('my-flag', value);
|
||||
const observedValues: number[] = [];
|
||||
flag$.subscribe((v) => observedValues.push(v));
|
||||
// Initial emission
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-flag': value });
|
||||
expect(observedValues).toHaveLength(1);
|
||||
|
||||
// Does not reevaluate and emit if the other flags are changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['another-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(1); // still 1
|
||||
|
||||
// Reevaluates and emits when the observed flag is changed
|
||||
addHandlerSpy.mock.calls[0][1]({ flagsChanged: ['my-flag'] });
|
||||
await expect(firstValueFrom(flag$)).resolves.toEqual(value);
|
||||
expect(observedValues).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('with overrides', async () => {
|
||||
const getBooleanValueSpy = jest.spyOn(featureFlagsClient, 'getBooleanValue');
|
||||
await expect(startContract.getBooleanValue('my-overridden-flag', false)).resolves.toEqual(
|
||||
true
|
||||
);
|
||||
expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-overridden-flag': true });
|
||||
expect(getBooleanValueSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Only to prove the spy works
|
||||
await expect(startContract.getBooleanValue('another-flag', false)).resolves.toEqual(false);
|
||||
expect(getBooleanValueSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getBooleanValueSpy).toHaveBeenCalledWith('another-flag', false);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns overrides', () => {
|
||||
const { getOverrides } = featureFlagsService.setup();
|
||||
expect(getOverrides()).toStrictEqual({ 'my-overridden-flag': true });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,196 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { CoreContext } from '@kbn/core-base-server-internal';
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FeatureFlagsSetup,
|
||||
FeatureFlagsStart,
|
||||
MultiContextEvaluationContext,
|
||||
} from '@kbn/core-feature-flags-server';
|
||||
import type { Logger } from '@kbn/logging';
|
||||
import apm from 'elastic-apm-node';
|
||||
import {
|
||||
type Client,
|
||||
OpenFeature,
|
||||
ServerProviderEvents,
|
||||
NOOP_PROVIDER,
|
||||
} from '@openfeature/server-sdk';
|
||||
import deepMerge from 'deepmerge';
|
||||
import { filter, switchMap, startWith, Subject } from 'rxjs';
|
||||
import { type FeatureFlagsConfig, featureFlagsConfig } from './feature_flags_config';
|
||||
|
||||
/**
|
||||
* Core-internal contract for the setup lifecycle step.
|
||||
* @private
|
||||
*/
|
||||
export interface InternalFeatureFlagsSetup extends FeatureFlagsSetup {
|
||||
/**
|
||||
* Used by the rendering service to share the overrides with the service on the browser side.
|
||||
*/
|
||||
getOverrides: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server-side Feature Flags Service
|
||||
* @private
|
||||
*/
|
||||
export class FeatureFlagsService {
|
||||
private readonly featureFlagsClient: Client;
|
||||
private readonly logger: Logger;
|
||||
private overrides: Record<string, unknown> = {};
|
||||
private context: MultiContextEvaluationContext = { kind: 'multi' };
|
||||
|
||||
/**
|
||||
* The core service's constructor
|
||||
* @param core {@link CoreContext}
|
||||
*/
|
||||
constructor(private readonly core: CoreContext) {
|
||||
this.logger = core.logger.get('feature-flags-service');
|
||||
this.featureFlagsClient = OpenFeature.getClient();
|
||||
OpenFeature.setLogger(this.logger.get('open-feature'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup lifecycle method
|
||||
*/
|
||||
public setup(): InternalFeatureFlagsSetup {
|
||||
// Register "overrides" to be changed via the dynamic config endpoint (enabled in test environments only)
|
||||
this.core.configService.addDynamicConfigPaths(featureFlagsConfig.path, ['overrides']);
|
||||
|
||||
this.core.configService
|
||||
.atPath<FeatureFlagsConfig>(featureFlagsConfig.path)
|
||||
.subscribe(({ overrides = {} }) => {
|
||||
this.overrides = overrides;
|
||||
});
|
||||
|
||||
return {
|
||||
getOverrides: () => this.overrides,
|
||||
setProvider: (provider) => {
|
||||
if (OpenFeature.providerMetadata !== NOOP_PROVIDER.metadata) {
|
||||
throw new Error('A provider has already been set. This API cannot be called twice.');
|
||||
}
|
||||
OpenFeature.setProvider(provider);
|
||||
},
|
||||
appendContext: (contextToAppend) => this.appendContext(contextToAppend),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start lifecycle method
|
||||
*/
|
||||
public start(): FeatureFlagsStart {
|
||||
const featureFlagsChanged$ = new Subject<string[]>();
|
||||
this.featureFlagsClient.addHandler(ServerProviderEvents.ConfigurationChanged, (event) => {
|
||||
if (event?.flagsChanged) {
|
||||
featureFlagsChanged$.next(event.flagsChanged);
|
||||
}
|
||||
});
|
||||
const observeFeatureFlag$ = (flagName: string) =>
|
||||
featureFlagsChanged$.pipe(
|
||||
filter((flagNames) => flagNames.includes(flagName)),
|
||||
startWith([flagName]) // only to emit on the first call
|
||||
);
|
||||
|
||||
return {
|
||||
appendContext: (contextToAppend) => this.appendContext(contextToAppend),
|
||||
getBooleanValue: async (flagName, fallbackValue) =>
|
||||
this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue),
|
||||
getStringValue: async <Value extends string>(flagName: string, fallbackValue: Value) =>
|
||||
await this.evaluateFlag<Value>(
|
||||
this.featureFlagsClient.getStringValue,
|
||||
flagName,
|
||||
fallbackValue
|
||||
),
|
||||
getNumberValue: async <Value extends number>(flagName: string, fallbackValue: Value) =>
|
||||
await this.evaluateFlag<Value>(
|
||||
this.featureFlagsClient.getNumberValue,
|
||||
flagName,
|
||||
fallbackValue
|
||||
),
|
||||
getBooleanValue$: (flagName, fallbackValue) => {
|
||||
return observeFeatureFlag$(flagName).pipe(
|
||||
switchMap(() =>
|
||||
this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue)
|
||||
)
|
||||
);
|
||||
},
|
||||
getStringValue$: <Value extends string>(flagName: string, fallbackValue: Value) => {
|
||||
return observeFeatureFlag$(flagName).pipe(
|
||||
switchMap(() =>
|
||||
this.evaluateFlag<Value>(
|
||||
this.featureFlagsClient.getStringValue,
|
||||
flagName,
|
||||
fallbackValue
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
getNumberValue$: <Value extends number>(flagName: string, fallbackValue: Value) => {
|
||||
return observeFeatureFlag$(flagName).pipe(
|
||||
switchMap(() =>
|
||||
this.evaluateFlag<Value>(
|
||||
this.featureFlagsClient.getNumberValue,
|
||||
flagName,
|
||||
fallbackValue
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop lifecycle method
|
||||
*/
|
||||
public async stop() {
|
||||
await OpenFeature.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper to evaluate flags with the common config overrides interceptions + APM and counters reporting
|
||||
* @param evaluationFn The actual evaluation API
|
||||
* @param flagName The name of the flag to evaluate
|
||||
* @param fallbackValue The fallback value
|
||||
* @private
|
||||
*/
|
||||
private async evaluateFlag<T extends string | boolean | number>(
|
||||
evaluationFn: (flagName: string, fallbackValue: T) => Promise<T>,
|
||||
flagName: string,
|
||||
fallbackValue: T
|
||||
): Promise<T> {
|
||||
const value =
|
||||
typeof this.overrides[flagName] !== 'undefined'
|
||||
? (this.overrides[flagName] as T)
|
||||
: // We have to bind the evaluation or the client will lose its internal context
|
||||
await evaluationFn.bind(this.featureFlagsClient)(flagName, fallbackValue);
|
||||
apm.addLabels({ [`flag_${flagName}`]: value });
|
||||
// TODO: increment usage counter
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the provided context to fulfill the expected multi-context structure.
|
||||
* @param contextToAppend The {@link EvaluationContext} to append.
|
||||
* @private
|
||||
*/
|
||||
private appendContext(contextToAppend: EvaluationContext): void {
|
||||
// If no kind provided, default to the project|deployment level.
|
||||
const { kind = 'kibana', ...rest } = contextToAppend;
|
||||
// Format the context to fulfill the expected multi-context structure
|
||||
const formattedContextToAppend: MultiContextEvaluationContext =
|
||||
kind === 'multi'
|
||||
? (contextToAppend as MultiContextEvaluationContext)
|
||||
: { kind: 'multi', [kind]: rest };
|
||||
|
||||
// Merge the formatted context to append to the global context, and set it in the OpenFeature client.
|
||||
this.context = deepMerge(this.context, formattedContextToAppend);
|
||||
OpenFeature.setContext(this.context);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core-base-server-internal",
|
||||
"@kbn/core-feature-flags-server",
|
||||
"@kbn/logging",
|
||||
"@kbn/core-base-server-mocks",
|
||||
"@kbn/config-schema",
|
||||
"@kbn/config-mocks",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/core-feature-flags-server-mocks
|
||||
|
||||
Server-side Jest mocks for the Feature Flags Service.
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import type {
|
||||
FeatureFlagsRequestHandlerContext,
|
||||
FeatureFlagsSetup,
|
||||
FeatureFlagsStart,
|
||||
} from '@kbn/core-feature-flags-server';
|
||||
import type {
|
||||
FeatureFlagsService,
|
||||
InternalFeatureFlagsSetup,
|
||||
} from '@kbn/core-feature-flags-server-internal';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
const createFeatureFlagsInternalSetup = (): jest.Mocked<InternalFeatureFlagsSetup> => {
|
||||
return {
|
||||
...createFeatureFlagsSetup(),
|
||||
getOverrides: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
};
|
||||
|
||||
const createFeatureFlagsSetup = (): jest.Mocked<FeatureFlagsSetup> => {
|
||||
return {
|
||||
setProvider: jest.fn(),
|
||||
appendContext: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const createFeatureFlagsStart = (): jest.Mocked<FeatureFlagsStart> => {
|
||||
return {
|
||||
appendContext: jest.fn(),
|
||||
getBooleanValue: jest.fn().mockImplementation(async (_, fallback) => fallback),
|
||||
getNumberValue: jest.fn().mockImplementation(async (_, fallback) => fallback),
|
||||
getStringValue: jest.fn().mockImplementation(async (_, fallback) => fallback),
|
||||
getBooleanValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)),
|
||||
getStringValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)),
|
||||
getNumberValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)),
|
||||
};
|
||||
};
|
||||
|
||||
const createRequestHandlerContext = (): jest.Mocked<FeatureFlagsRequestHandlerContext> => {
|
||||
return {
|
||||
getBooleanValue: jest.fn().mockImplementation(async (_, fallback) => fallback),
|
||||
getNumberValue: jest.fn().mockImplementation(async (_, fallback) => fallback),
|
||||
getStringValue: jest.fn().mockImplementation(async (_, fallback) => fallback),
|
||||
};
|
||||
};
|
||||
|
||||
const createFeatureFlagsServiceMock = (): jest.Mocked<PublicMethodsOf<FeatureFlagsService>> => {
|
||||
return {
|
||||
setup: jest.fn().mockImplementation(createFeatureFlagsInternalSetup),
|
||||
start: jest.fn().mockImplementation(createFeatureFlagsStart),
|
||||
stop: jest.fn().mockImplementation(Promise.resolve),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Mocks for the Feature Flags service (browser-side)
|
||||
*/
|
||||
export const coreFeatureFlagsMock = {
|
||||
/**
|
||||
* Mocks the entire feature flags service
|
||||
*/
|
||||
create: createFeatureFlagsServiceMock,
|
||||
/**
|
||||
* Mocks the core-internal setup contract
|
||||
*/
|
||||
createInternalSetup: createFeatureFlagsInternalSetup,
|
||||
/**
|
||||
* Mocks the setup contract
|
||||
*/
|
||||
createSetup: createFeatureFlagsSetup,
|
||||
/**
|
||||
* Mocks the start contract
|
||||
*/
|
||||
createStart: createFeatureFlagsStart,
|
||||
/**
|
||||
* Mocks the request handler context contract
|
||||
*/
|
||||
createRequestHandlerContext,
|
||||
};
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test/jest_node',
|
||||
rootDir: '../../../..',
|
||||
roots: ['<rootDir>/packages/core/feature-flags/core-feature-flags-server-mocks'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-server",
|
||||
"id": "@kbn/core-feature-flags-server-mocks",
|
||||
"owner": "@elastic/kibana-core"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/core-feature-flags-server-mocks",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/utility-types",
|
||||
"@kbn/core-feature-flags-server",
|
||||
"@kbn/core-feature-flags-server-internal",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/core-feature-flags-server
|
||||
|
||||
Server-side type definitions for the Feature Flags Service.
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export type {
|
||||
EvaluationContext,
|
||||
MultiContextEvaluationContext,
|
||||
SingleContextEvaluationContext,
|
||||
FeatureFlagsSetup,
|
||||
FeatureFlagsStart,
|
||||
} from './src/contracts';
|
||||
export type { FeatureFlagDefinition, FeatureFlagDefinitions } from './src/feature_flag_definition';
|
||||
export type { FeatureFlagsRequestHandlerContext } from './src/request_handler_context';
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-server",
|
||||
"id": "@kbn/core-feature-flags-server",
|
||||
"owner": "@elastic/kibana-core"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/core-feature-flags-server",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
|
||||
}
|
|
@ -0,0 +1,165 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { Provider } from '@openfeature/server-sdk';
|
||||
import { type EvaluationContext as OpenFeatureEvaluationContext } from '@openfeature/core';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* The evaluation context to use when retrieving the flags.
|
||||
*
|
||||
* We use multi-context so that we can apply segmentation rules at different levels (`organization`/`kibana`).
|
||||
* * `organization` includes any information that is common to all the projects/deployments in an organization. An example is the in_trial status.
|
||||
* * The `kibana` context includes all the information that identifies a project/deployment. Examples are version, offering, and has_data.
|
||||
* Kind helps us specify which sub-context should receive the new attributes.
|
||||
* If no `kind` is provided, it defaults to `kibana`.
|
||||
*
|
||||
* @example Providing properties for both contexts
|
||||
* {
|
||||
* kind: 'multi',
|
||||
* organization: {
|
||||
* key: 1234,
|
||||
* in_trial: true,
|
||||
* },
|
||||
* kibana: {
|
||||
* key: 12345567890,
|
||||
* version: 8.15.0,
|
||||
* buildHash: 'ffffffffaaaaaaaa',
|
||||
* },
|
||||
* }
|
||||
*
|
||||
* @example Appending context to the organization sub-context
|
||||
* {
|
||||
* kind: 'organization',
|
||||
* key: 1234,
|
||||
* in_trial: true,
|
||||
* }
|
||||
*
|
||||
* @example Appending context to the `kibana` sub-context
|
||||
* {
|
||||
* key: 12345567890,
|
||||
* version: 8.15.0,
|
||||
* buildHash: 'ffffffffaaaaaaaa',
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type EvaluationContext = MultiContextEvaluationContext | SingleContextEvaluationContext;
|
||||
|
||||
/**
|
||||
* Multi-context format. The sub-contexts are provided in their nested properties.
|
||||
* @public
|
||||
*/
|
||||
export type MultiContextEvaluationContext = OpenFeatureEvaluationContext & {
|
||||
/**
|
||||
* Static `multi` string
|
||||
*/
|
||||
kind: 'multi';
|
||||
/**
|
||||
* The Elastic Cloud organization-specific context.
|
||||
*/
|
||||
organization?: OpenFeatureEvaluationContext;
|
||||
/**
|
||||
* The deployment/project-specific context.
|
||||
*/
|
||||
kibana?: OpenFeatureEvaluationContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Single Context format. If `kind` is not specified, it applies to the `kibana` sub-context.
|
||||
*/
|
||||
export type SingleContextEvaluationContext = OpenFeatureEvaluationContext & {
|
||||
/**
|
||||
* The sub-context that it's updated. Defaults to `kibana`.
|
||||
*/
|
||||
kind?: 'organization' | 'kibana';
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup contract of the Feature Flags Service
|
||||
* @public
|
||||
*/
|
||||
export interface FeatureFlagsSetup {
|
||||
/**
|
||||
* Registers an OpenFeature provider to talk to the
|
||||
* 3rd-party service that manages the Feature Flags.
|
||||
* @param provider The {@link Provider | OpenFeature Provider} to handle the communication with the feature flags management system.
|
||||
* @public
|
||||
*/
|
||||
setProvider(provider: Provider): void;
|
||||
|
||||
/**
|
||||
* Appends new keys to the evaluation context.
|
||||
* @param contextToAppend The additional keys that should be appended/modified in the evaluation context.
|
||||
* @public
|
||||
*/
|
||||
appendContext(contextToAppend: EvaluationContext): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup contract of the Feature Flags Service
|
||||
* @public
|
||||
*/
|
||||
export interface FeatureFlagsStart {
|
||||
/**
|
||||
* Appends new keys to the evaluation context.
|
||||
* @param contextToAppend The additional keys that should be appended/modified in the evaluation context.
|
||||
* @public
|
||||
*/
|
||||
appendContext(contextToAppend: EvaluationContext): void;
|
||||
|
||||
/**
|
||||
* Evaluates a boolean flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getBooleanValue(flagName: string, fallbackValue: boolean): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Evaluates a string flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getStringValue<Value extends string>(flagName: string, fallbackValue: Value): Promise<Value>;
|
||||
|
||||
/**
|
||||
* Evaluates a number flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getNumberValue<Value extends number>(flagName: string, fallbackValue: Value): Promise<Value>;
|
||||
|
||||
/**
|
||||
* Returns an observable of a boolean flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getBooleanValue$(flagName: string, fallbackValue: boolean): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Returns an observable of a string flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getStringValue$<Value extends string>(flagName: string, fallbackValue: Value): Observable<Value>;
|
||||
|
||||
/**
|
||||
* Returns an observable of a number flag
|
||||
* @param flagName The flag ID to evaluate
|
||||
* @param fallbackValue If the flag cannot be evaluated for whatever reason, the fallback value is provided.
|
||||
* @public
|
||||
*/
|
||||
getNumberValue$<Value extends number>(flagName: string, fallbackValue: Value): Observable<Value>;
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
/**
|
||||
* List of {@link FeatureFlagDefinition}
|
||||
*/
|
||||
export type FeatureFlagDefinitions = Array<
|
||||
| FeatureFlagDefinition<'boolean'>
|
||||
| FeatureFlagDefinition<'string'>
|
||||
| FeatureFlagDefinition<'number'>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Definition of a feature flag
|
||||
*/
|
||||
export interface FeatureFlagDefinition<ValueType extends 'boolean' | 'string' | 'number'> {
|
||||
/**
|
||||
* The ID of the feature flag. Used to reference it when evaluating the flag.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Human friendly name.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Description of the purpose of the feature flag.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Tags to apply to the feature flag for easier categorizing. It may include the plugin, the solution, the team.
|
||||
*/
|
||||
tags: string[];
|
||||
/**
|
||||
* The type of the values returned by the feature flag ("string", "boolean", or "number").
|
||||
*/
|
||||
variationType: ValueType;
|
||||
/**
|
||||
* List of variations of the feature flags.
|
||||
*/
|
||||
variations: Array<{
|
||||
/**
|
||||
* Human friendly name of the variation.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Description of the variation.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* The value of the variation.
|
||||
*/
|
||||
value: ValueType extends 'string' ? string : ValueType extends 'boolean' ? boolean : number;
|
||||
}>;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { FeatureFlagsStart } from '..';
|
||||
|
||||
/**
|
||||
* The HTTP request handler context for evaluating feature flags
|
||||
*/
|
||||
export type FeatureFlagsRequestHandlerContext = Pick<
|
||||
FeatureFlagsStart,
|
||||
'getBooleanValue' | 'getStringValue' | 'getNumberValue'
|
||||
>;
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": []
|
||||
}
|
|
@ -33,12 +33,15 @@ import {
|
|||
CoreUserProfileRouteHandlerContext,
|
||||
type InternalUserProfileServiceStart,
|
||||
} from '@kbn/core-user-profile-server-internal';
|
||||
import { CoreFeatureFlagsRouteHandlerContext } from '@kbn/core-feature-flags-server-internal';
|
||||
import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server';
|
||||
|
||||
/**
|
||||
* Subset of `InternalCoreStart` used by {@link CoreRouteHandlerContext}
|
||||
* @internal
|
||||
*/
|
||||
export interface CoreRouteHandlerContextParams {
|
||||
featureFlags: FeatureFlagsStart;
|
||||
elasticsearch: InternalElasticsearchServiceStart;
|
||||
savedObjects: InternalSavedObjectsServiceStart;
|
||||
uiSettings: InternalUiSettingsServiceStart;
|
||||
|
@ -53,6 +56,7 @@ export interface CoreRouteHandlerContextParams {
|
|||
* @internal
|
||||
*/
|
||||
export class CoreRouteHandlerContext implements CoreRequestHandlerContext {
|
||||
#featureFlags?: CoreFeatureFlagsRouteHandlerContext;
|
||||
#elasticsearch?: CoreElasticsearchRouteHandlerContext;
|
||||
#savedObjects?: CoreSavedObjectsRouteHandlerContext;
|
||||
#uiSettings?: CoreUiSettingsRouteHandlerContext;
|
||||
|
@ -65,6 +69,13 @@ export class CoreRouteHandlerContext implements CoreRequestHandlerContext {
|
|||
private readonly request: KibanaRequest
|
||||
) {}
|
||||
|
||||
public get featureFlags() {
|
||||
if (!this.#featureFlags) {
|
||||
this.#featureFlags = new CoreFeatureFlagsRouteHandlerContext(this.coreStart.featureFlags);
|
||||
}
|
||||
return this.#featureFlags;
|
||||
}
|
||||
|
||||
public get elasticsearch() {
|
||||
if (!this.#elasticsearch) {
|
||||
this.#elasticsearch = new CoreElasticsearchRouteHandlerContext(
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
|
||||
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
|
||||
import { savedObjectsServiceMock } from '@kbn/core-saved-objects-server-mocks';
|
||||
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks';
|
||||
|
@ -16,6 +17,7 @@ import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks';
|
|||
|
||||
export const createCoreRouteHandlerContextParamsMock = () => {
|
||||
return {
|
||||
featureFlags: coreFeatureFlagsMock.createStart(),
|
||||
elasticsearch: elasticsearchServiceMock.createInternalStart(),
|
||||
savedObjects: savedObjectsServiceMock.createInternalStartContract(),
|
||||
uiSettings: uiSettingsServiceMock.createStartContract(),
|
||||
|
|
|
@ -27,6 +27,9 @@
|
|||
"@kbn/core-security-server-mocks",
|
||||
"@kbn/core-user-profile-server-internal",
|
||||
"@kbn/core-user-profile-server-mocks",
|
||||
"@kbn/core-feature-flags-server-internal",
|
||||
"@kbn/core-feature-flags-server",
|
||||
"@kbn/core-feature-flags-server-mocks",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -11,22 +11,34 @@ import type { RequestHandlerContextBase } from '@kbn/core-http-server';
|
|||
import type { IUiSettingsClient } from '@kbn/core-ui-settings-server';
|
||||
|
||||
/**
|
||||
* `uiSettings` http request context provider during the preboot phase.
|
||||
* @public
|
||||
*/
|
||||
export interface PrebootUiSettingsRequestHandlerContext {
|
||||
/**
|
||||
* The {@link IUiSettingsClient | UI Settings client}.
|
||||
*/
|
||||
client: IUiSettingsClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `core` context provided to route handler during the preboot phase.
|
||||
* @public
|
||||
*/
|
||||
export interface PrebootCoreRequestHandlerContext {
|
||||
/**
|
||||
* {@link PrebootUiSettingsRequestHandlerContext}
|
||||
*/
|
||||
uiSettings: PrebootUiSettingsRequestHandlerContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base context passed to a route handler during the preboot phase, containing the `core` context part.
|
||||
* @public
|
||||
*/
|
||||
export interface PrebootRequestHandlerContext extends RequestHandlerContextBase {
|
||||
/**
|
||||
* Promise that resolves the {@link PrebootCoreRequestHandlerContext}
|
||||
*/
|
||||
core: Promise<PrebootCoreRequestHandlerContext>;
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import type { DeprecationsRequestHandlerContext } from '@kbn/core-deprecations-s
|
|||
import type { UiSettingsRequestHandlerContext } from '@kbn/core-ui-settings-server';
|
||||
import type { SecurityRequestHandlerContext } from '@kbn/core-security-server';
|
||||
import type { UserProfileRequestHandlerContext } from '@kbn/core-user-profile-server';
|
||||
import type { FeatureFlagsRequestHandlerContext } from '@kbn/core-feature-flags-server';
|
||||
|
||||
/**
|
||||
* The `core` context provided to route handler.
|
||||
|
@ -30,11 +31,33 @@ import type { UserProfileRequestHandlerContext } from '@kbn/core-user-profile-se
|
|||
* @public
|
||||
*/
|
||||
export interface CoreRequestHandlerContext {
|
||||
/**
|
||||
* {@link SavedObjectsRequestHandlerContext}
|
||||
*/
|
||||
savedObjects: SavedObjectsRequestHandlerContext;
|
||||
/**
|
||||
* {@link ElasticsearchRequestHandlerContext}
|
||||
*/
|
||||
elasticsearch: ElasticsearchRequestHandlerContext;
|
||||
/**
|
||||
* {@link FeatureFlagsRequestHandlerContext}
|
||||
*/
|
||||
featureFlags: FeatureFlagsRequestHandlerContext;
|
||||
/**
|
||||
* {@link UiSettingsRequestHandlerContext}
|
||||
*/
|
||||
uiSettings: UiSettingsRequestHandlerContext;
|
||||
/**
|
||||
* {@link DeprecationsRequestHandlerContext}
|
||||
*/
|
||||
deprecations: DeprecationsRequestHandlerContext;
|
||||
/**
|
||||
* {@link SecurityRequestHandlerContext}
|
||||
*/
|
||||
security: SecurityRequestHandlerContext;
|
||||
/**
|
||||
* {@link UserProfileRequestHandlerContext}
|
||||
*/
|
||||
userProfile: UserProfileRequestHandlerContext;
|
||||
}
|
||||
|
||||
|
@ -44,6 +67,9 @@ export interface CoreRequestHandlerContext {
|
|||
* @public
|
||||
*/
|
||||
export interface RequestHandlerContext extends RequestHandlerContextBase {
|
||||
/**
|
||||
* Promise that resolves the {@link CoreRequestHandlerContext}
|
||||
*/
|
||||
core: Promise<CoreRequestHandlerContext>;
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,8 @@
|
|||
"@kbn/core-deprecations-server",
|
||||
"@kbn/core-ui-settings-server",
|
||||
"@kbn/core-security-server",
|
||||
"@kbn/core-user-profile-server"
|
||||
"@kbn/core-user-profile-server",
|
||||
"@kbn/core-feature-flags-server"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import type { DiscoveredPlugin } from '@kbn/core-base-common';
|
||||
import { InjectedMetadataService } from './injected_metadata_service';
|
||||
import type { InjectedMetadataParams } from '..';
|
||||
|
||||
describe('setup.getElasticsearchInfo()', () => {
|
||||
it('returns elasticsearch info from injectedMetadata', () => {
|
||||
|
@ -160,3 +161,29 @@ describe('setup.getLegacyMetadata()', () => {
|
|||
}).toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup.getFeatureFlags()', () => {
|
||||
it('returns injectedMetadata.featureFlags', () => {
|
||||
const injectedMetadata = new InjectedMetadataService({
|
||||
injectedMetadata: {
|
||||
featureFlags: {
|
||||
overrides: {
|
||||
'my-overridden-flag': 1234,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as InjectedMetadataParams);
|
||||
|
||||
const contract = injectedMetadata.setup();
|
||||
expect(contract.getFeatureFlags()).toStrictEqual({ overrides: { 'my-overridden-flag': 1234 } });
|
||||
});
|
||||
|
||||
it('returns empty injectedMetadata.featureFlags', () => {
|
||||
const injectedMetadata = new InjectedMetadataService({
|
||||
injectedMetadata: {},
|
||||
} as unknown as InjectedMetadataParams);
|
||||
|
||||
const contract = injectedMetadata.setup();
|
||||
expect(contract.getFeatureFlags()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -95,6 +95,10 @@ export class InjectedMetadataService {
|
|||
getCustomBranding: () => {
|
||||
return this.state.customBranding;
|
||||
},
|
||||
|
||||
getFeatureFlags: () => {
|
||||
return this.state.featureFlags;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,6 +58,11 @@ export interface InternalInjectedMetadataSetup {
|
|||
};
|
||||
};
|
||||
getCustomBranding: () => CustomBranding;
|
||||
getFeatureFlags: () =>
|
||||
| {
|
||||
overrides: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
|
|
@ -30,6 +30,7 @@ const createSetupContractMock = () => {
|
|||
getPlugins: jest.fn(),
|
||||
getKibanaBuildNumber: jest.fn(),
|
||||
getCustomBranding: jest.fn(),
|
||||
getFeatureFlags: jest.fn(),
|
||||
};
|
||||
setupContract.getBasePath.mockReturnValue('/base-path');
|
||||
setupContract.getServerBasePath.mockReturnValue('/server-base-path');
|
||||
|
|
|
@ -63,6 +63,9 @@ export interface InjectedMetadata {
|
|||
mode: EnvironmentMode;
|
||||
packageInfo: PackageInfo;
|
||||
};
|
||||
featureFlags?: {
|
||||
overrides: Record<string, unknown>;
|
||||
};
|
||||
anonymousStatusPage: boolean;
|
||||
i18n: {
|
||||
translationsUrl: string;
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-
|
|||
import type { InternalHttpSetup } from '@kbn/core-http-browser-internal';
|
||||
import type { InternalSecurityServiceSetup } from '@kbn/core-security-browser-internal';
|
||||
import type { InternalUserProfileServiceSetup } from '@kbn/core-user-profile-browser-internal';
|
||||
import type { FeatureFlagsSetup } from '@kbn/core-feature-flags-browser';
|
||||
|
||||
/** @internal */
|
||||
export interface InternalCoreSetup
|
||||
|
@ -21,6 +22,7 @@ export interface InternalCoreSetup
|
|||
'application' | 'plugins' | 'getStartServices' | 'http' | 'security' | 'userProfile'
|
||||
> {
|
||||
application: InternalApplicationSetup;
|
||||
featureFlags: FeatureFlagsSetup;
|
||||
injectedMetadata: InternalInjectedMetadataSetup;
|
||||
http: InternalHttpSetup;
|
||||
security: InternalSecurityServiceSetup;
|
||||
|
|
|
@ -13,11 +13,13 @@ import type { InternalInjectedMetadataStart } from '@kbn/core-injected-metadata-
|
|||
import type { InternalHttpStart } from '@kbn/core-http-browser-internal';
|
||||
import type { InternalSecurityServiceStart } from '@kbn/core-security-browser-internal';
|
||||
import type { InternalUserProfileServiceStart } from '@kbn/core-user-profile-browser-internal';
|
||||
import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser';
|
||||
|
||||
/** @internal */
|
||||
export interface InternalCoreStart
|
||||
extends Omit<CoreStart, 'application' | 'plugins' | 'http' | 'security' | 'userProfile'> {
|
||||
application: InternalApplicationStart;
|
||||
featureFlags: FeatureFlagsStart;
|
||||
injectedMetadata: InternalInjectedMetadataStart;
|
||||
http: InternalHttpStart;
|
||||
security: InternalSecurityServiceStart;
|
||||
|
|
|
@ -17,7 +17,8 @@
|
|||
"@kbn/core-injected-metadata-browser-internal",
|
||||
"@kbn/core-http-browser-internal",
|
||||
"@kbn/core-security-browser-internal",
|
||||
"@kbn/core-user-profile-browser-internal"
|
||||
"@kbn/core-user-profile-browser-internal",
|
||||
"@kbn/core-feature-flags-browser"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -21,6 +21,7 @@ import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-moc
|
|||
import { securityServiceMock } from '@kbn/core-security-browser-mocks';
|
||||
import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks';
|
||||
import { createCoreStartMock } from './core_start.mock';
|
||||
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks';
|
||||
|
||||
export function createCoreSetupMock({
|
||||
basePath = '',
|
||||
|
@ -38,6 +39,7 @@ export function createCoreSetupMock({
|
|||
docLinks: docLinksServiceMock.createSetupContract(),
|
||||
executionContext: executionContextServiceMock.createSetupContract(),
|
||||
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
|
||||
featureFlags: coreFeatureFlagsMock.createSetup(),
|
||||
getStartServices: jest.fn<Promise<[ReturnType<typeof createCoreStartMock>, any, any]>, []>(() =>
|
||||
Promise.resolve([createCoreStartMock({ basePath }), pluginStartDeps, pluginStartContract])
|
||||
),
|
||||
|
|
|
@ -24,6 +24,7 @@ import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
|
|||
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
|
||||
import { securityServiceMock } from '@kbn/core-security-browser-mocks';
|
||||
import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks';
|
||||
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks';
|
||||
|
||||
export function createCoreStartMock({ basePath = '' } = {}) {
|
||||
const mock = {
|
||||
|
@ -33,6 +34,7 @@ export function createCoreStartMock({ basePath = '' } = {}) {
|
|||
customBranding: customBrandingServiceMock.createStartContract(),
|
||||
docLinks: docLinksServiceMock.createStartContract(),
|
||||
executionContext: executionContextServiceMock.createStartContract(),
|
||||
featureFlags: coreFeatureFlagsMock.createStart(),
|
||||
http: httpServiceMock.createStartContract({ basePath }),
|
||||
i18n: i18nServiceMock.createStartContract(),
|
||||
notifications: notificationServiceMock.createStartContract(),
|
||||
|
|
|
@ -28,7 +28,8 @@
|
|||
"@kbn/core-chrome-browser-mocks",
|
||||
"@kbn/core-custom-branding-browser-mocks",
|
||||
"@kbn/core-security-browser-mocks",
|
||||
"@kbn/core-user-profile-browser-mocks"
|
||||
"@kbn/core-user-profile-browser-mocks",
|
||||
"@kbn/core-feature-flags-browser-mocks"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
import type { ThemeServiceSetup } from '@kbn/core-theme-browser';
|
||||
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
|
||||
import type { ExecutionContextSetup } from '@kbn/core-execution-context-browser';
|
||||
import type { FeatureFlagsSetup } from '@kbn/core-feature-flags-browser';
|
||||
import type { HttpSetup } from '@kbn/core-http-browser';
|
||||
import type { FatalErrorsSetup } from '@kbn/core-fatal-errors-browser';
|
||||
import type { IUiSettingsClient, SettingsStart } from '@kbn/core-ui-settings-browser';
|
||||
|
@ -44,6 +45,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
|
|||
customBranding: CustomBrandingSetup;
|
||||
/** {@link FatalErrorsSetup} */
|
||||
fatalErrors: FatalErrorsSetup;
|
||||
/** {@link FeatureFlagsSetup} */
|
||||
featureFlags: FeatureFlagsSetup;
|
||||
/** {@link HttpSetup} */
|
||||
http: HttpSetup;
|
||||
/** {@link NotificationsSetup} */
|
||||
|
|
|
@ -25,6 +25,7 @@ import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser';
|
|||
import type { PluginsServiceStart } from '@kbn/core-plugins-contracts-browser';
|
||||
import type { SecurityServiceStart } from '@kbn/core-security-browser';
|
||||
import type { UserProfileServiceStart } from '@kbn/core-user-profile-browser';
|
||||
import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser';
|
||||
|
||||
/**
|
||||
* Core services exposed to the `Plugin` start lifecycle
|
||||
|
@ -48,6 +49,8 @@ export interface CoreStart {
|
|||
docLinks: DocLinksStart;
|
||||
/** {@link ExecutionContextStart} */
|
||||
executionContext: ExecutionContextStart;
|
||||
/** {@link FeatureFlagsStart} */
|
||||
featureFlags: FeatureFlagsStart;
|
||||
/** {@link HttpStart} */
|
||||
http: HttpStart;
|
||||
/**
|
||||
|
|
|
@ -29,7 +29,8 @@
|
|||
"@kbn/core-custom-branding-browser",
|
||||
"@kbn/core-plugins-contracts-browser",
|
||||
"@kbn/core-security-browser",
|
||||
"@kbn/core-user-profile-browser"
|
||||
"@kbn/core-user-profile-browser",
|
||||
"@kbn/core-feature-flags-browser"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -29,6 +29,7 @@ import type { InternalCustomBrandingSetup } from '@kbn/core-custom-branding-serv
|
|||
import type { InternalUserSettingsServiceSetup } from '@kbn/core-user-settings-server-internal';
|
||||
import type { InternalSecurityServiceSetup } from '@kbn/core-security-server-internal';
|
||||
import type { InternalUserProfileServiceSetup } from '@kbn/core-user-profile-server-internal';
|
||||
import type { InternalFeatureFlagsSetup } from '@kbn/core-feature-flags-server-internal';
|
||||
|
||||
/** @internal */
|
||||
export interface InternalCoreSetup {
|
||||
|
@ -39,6 +40,7 @@ export interface InternalCoreSetup {
|
|||
http: InternalHttpServiceSetup;
|
||||
elasticsearch: InternalElasticsearchServiceSetup;
|
||||
executionContext: InternalExecutionContextSetup;
|
||||
featureFlags: InternalFeatureFlagsSetup;
|
||||
i18n: I18nServiceSetup;
|
||||
savedObjects: InternalSavedObjectsServiceSetup;
|
||||
status: InternalStatusServiceSetup;
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { InternalDeprecationsServiceStart } from '@kbn/core-deprecations-se
|
|||
import type { DocLinksServiceStart } from '@kbn/core-doc-links-server';
|
||||
import type { InternalElasticsearchServiceStart } from '@kbn/core-elasticsearch-server-internal';
|
||||
import type { InternalExecutionContextStart } from '@kbn/core-execution-context-server-internal';
|
||||
import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server';
|
||||
import type { InternalHttpServiceStart } from '@kbn/core-http-server-internal';
|
||||
import type { InternalMetricsServiceStart } from '@kbn/core-metrics-server-internal';
|
||||
import type { InternalSavedObjectsServiceStart } from '@kbn/core-saved-objects-server-internal';
|
||||
|
@ -29,6 +30,7 @@ export interface InternalCoreStart {
|
|||
analytics: AnalyticsServiceStart;
|
||||
capabilities: CapabilitiesStart;
|
||||
elasticsearch: InternalElasticsearchServiceStart;
|
||||
featureFlags: FeatureFlagsStart;
|
||||
docLinks: DocLinksServiceStart;
|
||||
http: InternalHttpServiceStart;
|
||||
metrics: InternalMetricsServiceStart;
|
||||
|
|
|
@ -35,7 +35,9 @@
|
|||
"@kbn/core-custom-branding-server",
|
||||
"@kbn/core-user-settings-server-internal",
|
||||
"@kbn/core-security-server-internal",
|
||||
"@kbn/core-user-profile-server-internal"
|
||||
"@kbn/core-user-profile-server-internal",
|
||||
"@kbn/core-feature-flags-server",
|
||||
"@kbn/core-feature-flags-server-internal"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -30,6 +30,7 @@ import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks';
|
|||
import { securityServiceMock } from '@kbn/core-security-server-mocks';
|
||||
import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks';
|
||||
import { createCoreStartMock } from './core_start.mock';
|
||||
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
|
||||
|
||||
type CoreSetupMockType = MockedKeys<CoreSetup> & {
|
||||
elasticsearch: ReturnType<typeof elasticsearchServiceMock.createSetup>;
|
||||
|
@ -61,6 +62,7 @@ export function createCoreSetupMock({
|
|||
userSettings: userSettingsServiceMock.createSetupContract(),
|
||||
docLinks: docLinksServiceMock.createSetupContract(),
|
||||
elasticsearch: elasticsearchServiceMock.createSetup(),
|
||||
featureFlags: coreFeatureFlagsMock.createSetup(),
|
||||
http: httpMock,
|
||||
i18n: i18nServiceMock.createSetupContract(),
|
||||
savedObjects: savedObjectsServiceMock.createInternalSetupContract(),
|
||||
|
|
|
@ -22,6 +22,7 @@ import { coreUsageDataServiceMock } from '@kbn/core-usage-data-server-mocks';
|
|||
import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks';
|
||||
import { securityServiceMock } from '@kbn/core-security-server-mocks';
|
||||
import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks';
|
||||
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
|
||||
|
||||
export function createCoreStartMock() {
|
||||
const mock: MockedKeys<CoreStart> = {
|
||||
|
@ -29,6 +30,7 @@ export function createCoreStartMock() {
|
|||
capabilities: capabilitiesServiceMock.createStartContract(),
|
||||
docLinks: docLinksServiceMock.createStartContract(),
|
||||
elasticsearch: elasticsearchServiceMock.createStart(),
|
||||
featureFlags: coreFeatureFlagsMock.createStart(),
|
||||
http: httpServiceMock.createStartContract(),
|
||||
metrics: metricsServiceMock.createStartContract(),
|
||||
savedObjects: savedObjectsServiceMock.createStartContract(),
|
||||
|
|
|
@ -29,6 +29,7 @@ import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mock
|
|||
import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks';
|
||||
import { securityServiceMock } from '@kbn/core-security-server-mocks';
|
||||
import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks';
|
||||
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
|
||||
|
||||
export function createInternalCoreSetupMock() {
|
||||
const setupDeps = {
|
||||
|
@ -37,6 +38,7 @@ export function createInternalCoreSetupMock() {
|
|||
context: contextServiceMock.createSetupContract(),
|
||||
docLinks: docLinksServiceMock.createSetupContract(),
|
||||
elasticsearch: elasticsearchServiceMock.createInternalSetup(),
|
||||
featureFlags: coreFeatureFlagsMock.createInternalSetup(),
|
||||
http: httpServiceMock.createInternalSetupContract(),
|
||||
savedObjects: savedObjectsServiceMock.createInternalSetupContract(),
|
||||
status: statusServiceMock.createInternalSetupContract(),
|
||||
|
|
|
@ -21,6 +21,7 @@ import { coreUsageDataServiceMock } from '@kbn/core-usage-data-server-mocks';
|
|||
import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks';
|
||||
import { securityServiceMock } from '@kbn/core-security-server-mocks';
|
||||
import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks';
|
||||
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
|
||||
|
||||
export function createInternalCoreStartMock() {
|
||||
const startDeps = {
|
||||
|
@ -28,6 +29,7 @@ export function createInternalCoreStartMock() {
|
|||
capabilities: capabilitiesServiceMock.createStartContract(),
|
||||
docLinks: docLinksServiceMock.createStartContract(),
|
||||
elasticsearch: elasticsearchServiceMock.createInternalStart(),
|
||||
featureFlags: coreFeatureFlagsMock.createStart(),
|
||||
http: httpServiceMock.createInternalStartContract(),
|
||||
metrics: metricsServiceMock.createInternalStartContract(),
|
||||
savedObjects: savedObjectsServiceMock.createInternalStartContract(),
|
||||
|
|
|
@ -37,6 +37,7 @@
|
|||
"@kbn/core-user-settings-server-mocks",
|
||||
"@kbn/core-security-server-mocks",
|
||||
"@kbn/core-user-profile-server-mocks",
|
||||
"@kbn/core-feature-flags-server-mocks",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { DeprecationsServiceSetup } from '@kbn/core-deprecations-server';
|
|||
import type { DocLinksServiceSetup } from '@kbn/core-doc-links-server';
|
||||
import type { ElasticsearchServiceSetup } from '@kbn/core-elasticsearch-server';
|
||||
import type { ExecutionContextSetup } from '@kbn/core-execution-context-server';
|
||||
import type { FeatureFlagsSetup } from '@kbn/core-feature-flags-server';
|
||||
import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
|
||||
import type { HttpResources } from '@kbn/core-http-resources-server';
|
||||
import type { HttpServiceSetup } from '@kbn/core-http-server';
|
||||
|
@ -52,6 +53,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
|
|||
elasticsearch: ElasticsearchServiceSetup;
|
||||
/** {@link ExecutionContextSetup} */
|
||||
executionContext: ExecutionContextSetup;
|
||||
/** {@link FeatureFlagsSetup} */
|
||||
featureFlags: FeatureFlagsSetup;
|
||||
/** {@link HttpServiceSetup} */
|
||||
http: HttpServiceSetup<RequestHandlerContext> & {
|
||||
/** {@link HttpResources} */
|
||||
|
|
|
@ -12,6 +12,7 @@ import type { CapabilitiesStart } from '@kbn/core-capabilities-server';
|
|||
import type { DocLinksServiceStart } from '@kbn/core-doc-links-server';
|
||||
import type { ElasticsearchServiceStart } from '@kbn/core-elasticsearch-server';
|
||||
import type { ExecutionContextStart } from '@kbn/core-execution-context-server';
|
||||
import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server';
|
||||
import type { HttpServiceStart } from '@kbn/core-http-server';
|
||||
import type { MetricsServiceStart } from '@kbn/core-metrics-server';
|
||||
import type { SavedObjectsServiceStart } from '@kbn/core-saved-objects-server';
|
||||
|
@ -40,6 +41,8 @@ export interface CoreStart {
|
|||
elasticsearch: ElasticsearchServiceStart;
|
||||
/** {@link ExecutionContextStart} */
|
||||
executionContext: ExecutionContextStart;
|
||||
/** {@link FeatureFlagsStart} */
|
||||
featureFlags: FeatureFlagsStart;
|
||||
/** {@link HttpServiceStart} */
|
||||
http: HttpServiceStart;
|
||||
/** {@link MetricsServiceStart} */
|
||||
|
|
|
@ -32,7 +32,8 @@
|
|||
"@kbn/core-user-settings-server",
|
||||
"@kbn/core-plugins-contracts-server",
|
||||
"@kbn/core-security-server",
|
||||
"@kbn/core-user-profile-server"
|
||||
"@kbn/core-user-profile-server",
|
||||
"@kbn/core-feature-flags-server"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -82,6 +82,7 @@ export function createPluginSetupContext<
|
|||
},
|
||||
customBranding: deps.customBranding,
|
||||
fatalErrors: deps.fatalErrors,
|
||||
featureFlags: deps.featureFlags,
|
||||
executionContext: deps.executionContext,
|
||||
http: {
|
||||
...deps.http,
|
||||
|
@ -147,6 +148,7 @@ export function createPluginStartContext<
|
|||
customBranding: deps.customBranding,
|
||||
docLinks: deps.docLinks,
|
||||
executionContext: deps.executionContext,
|
||||
featureFlags: deps.featureFlags,
|
||||
http: {
|
||||
...deps.http,
|
||||
staticAssets: {
|
||||
|
|
|
@ -218,6 +218,10 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>({
|
|||
withContext: deps.executionContext.withContext,
|
||||
getAsLabels: deps.executionContext.getAsLabels,
|
||||
},
|
||||
featureFlags: {
|
||||
setProvider: deps.featureFlags.setProvider,
|
||||
appendContext: deps.featureFlags.appendContext,
|
||||
},
|
||||
http: {
|
||||
createCookieSessionStorageFactory: deps.http.createCookieSessionStorageFactory,
|
||||
registerRouteHandlerContext: <
|
||||
|
@ -332,6 +336,15 @@ export function createPluginStartContext<TPlugin, TPluginDependencies>({
|
|||
getCapabilities: deps.elasticsearch.getCapabilities,
|
||||
},
|
||||
executionContext: deps.executionContext,
|
||||
featureFlags: {
|
||||
appendContext: deps.featureFlags.appendContext,
|
||||
getBooleanValue: deps.featureFlags.getBooleanValue,
|
||||
getStringValue: deps.featureFlags.getStringValue,
|
||||
getNumberValue: deps.featureFlags.getNumberValue,
|
||||
getBooleanValue$: deps.featureFlags.getBooleanValue$,
|
||||
getStringValue$: deps.featureFlags.getStringValue$,
|
||||
getNumberValue$: deps.featureFlags.getNumberValue$,
|
||||
},
|
||||
http: {
|
||||
auth: deps.http.auth,
|
||||
basePath: deps.http.basePath,
|
||||
|
|
|
@ -39,6 +39,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -121,6 +124,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -199,6 +205,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -281,6 +290,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -359,6 +371,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -437,6 +452,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -519,6 +537,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -597,6 +618,90 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": 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",
|
||||
"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 feature flags overrides 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 {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -680,6 +785,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -762,6 +870,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -845,6 +956,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -932,6 +1046,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -1010,6 +1127,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -1093,6 +1213,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -1180,6 +1303,9 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": Object {
|
||||
"overrides": Object {},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
@ -1263,6 +1389,97 @@ Object {
|
|||
},
|
||||
],
|
||||
},
|
||||
"featureFlags": 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",
|
||||
"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 setup() render() renders feature flags overrides 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 {
|
||||
"overrides": Object {
|
||||
"my-overridden-flag": 1234,
|
||||
},
|
||||
},
|
||||
"i18n": Object {
|
||||
"translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json",
|
||||
},
|
||||
|
|
|
@ -82,6 +82,10 @@ function renderTestCases(
|
|||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockRenderingSetupDeps.featureFlags.getOverrides.mockReset();
|
||||
});
|
||||
|
||||
it('renders "core" page', async () => {
|
||||
const [render] = await getRender();
|
||||
const content = await render(createKibanaRequest(), uiSettings);
|
||||
|
@ -245,6 +249,19 @@ function renderTestCases(
|
|||
expect(data).toMatchSnapshot(INJECTED_METADATA);
|
||||
});
|
||||
|
||||
it('renders feature flags overrides', async () => {
|
||||
mockRenderingSetupDeps.featureFlags.getOverrides.mockReturnValueOnce({
|
||||
'my-overridden-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: {
|
||||
|
|
|
@ -51,6 +51,7 @@ type RenderOptions =
|
|||
| (RenderingPrebootDeps & {
|
||||
status?: never;
|
||||
elasticsearch?: never;
|
||||
featureFlags?: never;
|
||||
customBranding?: never;
|
||||
userSettings?: never;
|
||||
});
|
||||
|
@ -85,6 +86,7 @@ export class RenderingService {
|
|||
|
||||
public async setup({
|
||||
elasticsearch,
|
||||
featureFlags,
|
||||
http,
|
||||
status,
|
||||
uiPlugins,
|
||||
|
@ -106,6 +108,7 @@ export class RenderingService {
|
|||
return {
|
||||
render: this.render.bind(this, {
|
||||
elasticsearch,
|
||||
featureFlags,
|
||||
http,
|
||||
uiPlugins,
|
||||
status,
|
||||
|
@ -125,8 +128,16 @@ export class RenderingService {
|
|||
},
|
||||
{ isAnonymousPage = false, includeExposedConfigKeys }: IRenderOptions = {}
|
||||
) {
|
||||
const { elasticsearch, http, uiPlugins, status, customBranding, userSettings, i18n } =
|
||||
renderOptions;
|
||||
const {
|
||||
elasticsearch,
|
||||
featureFlags,
|
||||
http,
|
||||
uiPlugins,
|
||||
status,
|
||||
customBranding,
|
||||
userSettings,
|
||||
i18n,
|
||||
} = renderOptions;
|
||||
|
||||
const env = {
|
||||
mode: this.coreContext.env.mode,
|
||||
|
@ -251,6 +262,9 @@ export class RenderingService {
|
|||
assetsHrefBase: staticAssetsHrefBase,
|
||||
logging: loggingConfig,
|
||||
env,
|
||||
featureFlags: {
|
||||
overrides: featureFlags?.getOverrides() || {},
|
||||
},
|
||||
clusterInfo,
|
||||
apmConfig,
|
||||
anonymousStatusPage: status?.isStatusPageAnonymous() ?? false,
|
||||
|
|
|
@ -14,6 +14,7 @@ import { statusServiceMock } from '@kbn/core-status-server-mocks';
|
|||
import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks';
|
||||
import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks';
|
||||
import { i18nServiceMock } from '@kbn/core-i18n-server-mocks';
|
||||
import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks';
|
||||
|
||||
const context = mockCoreContext.create();
|
||||
const httpPreboot = httpServiceMock.createInternalPrebootContract();
|
||||
|
@ -39,6 +40,7 @@ export const mockRenderingPrebootDeps = {
|
|||
};
|
||||
export const mockRenderingSetupDeps = {
|
||||
elasticsearch,
|
||||
featureFlags: coreFeatureFlagsMock.createInternalSetup(),
|
||||
http: httpSetup,
|
||||
uiPlugins: createUiPlugins(),
|
||||
customBranding,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue