[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:
Alejandro Fernández Haro 2024-09-18 18:02:55 +02:00 committed by GitHub
parent 38d6143f72
commit 02ce1b9101
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
164 changed files with 3605 additions and 1941 deletions

7
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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 @@
]
}
]
}
}

View file

@ -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]

View 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.

View 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';

View 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';

View 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": []
}
}

View 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);
};

View 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>
</>
);
};

View 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();
}

View 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() {}
}

View 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;
}

View 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);
}

View 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() {}
}

View 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),
},
});
}
);
}

View 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",
]
}

View file

@ -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",

View file

@ -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 {

View 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"
}
}
```

View file

@ -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.

View file

@ -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';

View 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".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/feature-flags/core-feature-flags-browser-internal'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/core-feature-flags-browser-internal",
"owner": "@elastic/kibana-core"
}

View file

@ -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"
}

View file

@ -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);
});
});
});

View file

@ -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);
}
}

View file

@ -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",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/core-feature-flags-browser-mocks
Browser-side Jest mocks for the Feature Flags Service.

View file

@ -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,
};

View 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".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/feature-flags/core-feature-flags-browser-mocks'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/core-feature-flags-browser-mocks",
"owner": "@elastic/kibana-core"
}

View file

@ -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"
}

View file

@ -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",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/core-feature-flags-browser
Browser-side type definitions for the Feature Flags Service.

View file

@ -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';

View file

@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/core-feature-flags-browser",
"owner": "@elastic/kibana-core"
}

View file

@ -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"
}

View file

@ -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>;
}

View file

@ -0,0 +1,18 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}

View file

@ -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.

View 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 { featureFlagsConfig } from './src/feature_flags_config';
export { FeatureFlagsService, type InternalFeatureFlagsSetup } from './src/feature_flags_service';
export { CoreFeatureFlagsRouteHandlerContext } from './src/feature_flags_request_handler_context';

View 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".
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/feature-flags/core-feature-flags-server-internal'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-server",
"id": "@kbn/core-feature-flags-server-internal",
"owner": "@elastic/kibana-core"
}

View file

@ -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"
}

View file

@ -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,
};

View 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 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);
}
}

View file

@ -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 });
});
});

View file

@ -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);
}
}

View file

@ -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",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/core-feature-flags-server-mocks
Server-side Jest mocks for the Feature Flags Service.

View file

@ -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,
};

View 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".
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/feature-flags/core-feature-flags-server-mocks'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-server",
"id": "@kbn/core-feature-flags-server-mocks",
"owner": "@elastic/kibana-core"
}

View file

@ -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"
}

View file

@ -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",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/core-feature-flags-server
Server-side type definitions for the Feature Flags Service.

View file

@ -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';

View file

@ -0,0 +1,5 @@
{
"type": "shared-server",
"id": "@kbn/core-feature-flags-server",
"owner": "@elastic/kibana-core"
}

View file

@ -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"
}

View file

@ -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>;
}

View file

@ -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;
}>;
}

View file

@ -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'
>;

View file

@ -0,0 +1,16 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}

View file

@ -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(

View file

@ -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(),

View file

@ -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/**/*",

View file

@ -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>;
}

View file

@ -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>;
}

View file

@ -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/**/*",

View file

@ -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();
});
});

View file

@ -95,6 +95,10 @@ export class InjectedMetadataService {
getCustomBranding: () => {
return this.state.customBranding;
},
getFeatureFlags: () => {
return this.state.featureFlags;
},
};
}
}

View file

@ -58,6 +58,11 @@ export interface InternalInjectedMetadataSetup {
};
};
getCustomBranding: () => CustomBranding;
getFeatureFlags: () =>
| {
overrides: Record<string, unknown>;
}
| undefined;
}
/** @internal */

View file

@ -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');

View file

@ -63,6 +63,9 @@ export interface InjectedMetadata {
mode: EnvironmentMode;
packageInfo: PackageInfo;
};
featureFlags?: {
overrides: Record<string, unknown>;
};
anonymousStatusPage: boolean;
i18n: {
translationsUrl: string;

View file

@ -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;

View file

@ -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;

View file

@ -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/**/*",

View file

@ -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])
),

View file

@ -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(),

View file

@ -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/**/*",

View file

@ -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} */

View file

@ -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;
/**

View file

@ -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/**/*",

View file

@ -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;

View file

@ -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;

View file

@ -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/**/*",

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -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/**/*",

View file

@ -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} */

View file

@ -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} */

View file

@ -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/**/*",

View file

@ -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: {

View file

@ -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,

View file

@ -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",
},

View file

@ -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: {

View file

@ -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,

View file

@ -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