From 02ce1b91014eb59f019854b01325bbdf983d2218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 18 Sep 2024 18:02:55 +0200 Subject: [PATCH] =?UTF-8?q?[Feature=20Flags=20Service]=20Hello=20world=20?= =?UTF-8?q?=F0=9F=91=8B=20(#188562)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Jean-Louis Leysens --- .github/CODEOWNERS | 7 + dev_docs/nav-kibana-dev.docnav.json | 10 +- docs/developer/plugin-list.asciidoc | 4 +- examples/feature_flags_example/README.md | 5 + .../common/feature_flags.ts | 12 + .../feature_flags_example/common/index.ts | 11 + examples/feature_flags_example/kibana.jsonc | 13 + .../public/application.tsx | 33 ++ .../public/components/app.tsx | 91 ++++++ .../feature_flags_example/public/index.ts | 14 + .../feature_flags_example/public/plugin.ts | 40 +++ .../feature_flags_example/public/types.ts | 14 + .../feature_flags_example/server/index.ts | 77 +++++ .../feature_flags_example/server/plugin.ts | 69 +++++ .../server/routes/index.ts | 44 +++ examples/feature_flags_example/tsconfig.json | 24 ++ package.json | 13 +- .../elasticsearch_route_handler_context.ts | 2 +- packages/core/feature-flags/README.mdx | 158 ++++++++++ .../README.md | 5 + .../index.ts | 10 + .../jest.config.js | 14 + .../kibana.jsonc | 5 + .../package.json | 6 + .../src/feature_flags_service.test.ts | 292 ++++++++++++++++++ .../src/feature_flags_service.ts | 203 ++++++++++++ .../tsconfig.json | 26 ++ .../README.md | 3 + .../core-feature-flags-browser-mocks/index.ts | 58 ++++ .../jest.config.js | 14 + .../kibana.jsonc | 5 + .../package.json | 6 + .../tsconfig.json | 23 ++ .../core-feature-flags-browser/README.md | 3 + .../core-feature-flags-browser/index.ts | 16 + .../core-feature-flags-browser/kibana.jsonc | 5 + .../core-feature-flags-browser/package.json | 6 + .../core-feature-flags-browser/src/types.ts | 165 ++++++++++ .../core-feature-flags-browser/tsconfig.json | 18 ++ .../README.md | 5 + .../index.ts | 12 + .../jest.config.js | 14 + .../kibana.jsonc | 5 + .../package.json | 6 + .../src/feature_flags_config.ts | 42 +++ .../feature_flags_request_handler_context.ts | 33 ++ .../src/feature_flags_service.test.ts | 260 ++++++++++++++++ .../src/feature_flags_service.ts | 196 ++++++++++++ .../tsconfig.json | 24 ++ .../core-feature-flags-server-mocks/README.md | 3 + .../core-feature-flags-server-mocks/index.ts | 88 ++++++ .../jest.config.js | 14 + .../kibana.jsonc | 5 + .../package.json | 6 + .../tsconfig.json | 21 ++ .../core-feature-flags-server/README.md | 3 + .../core-feature-flags-server/index.ts | 18 ++ .../core-feature-flags-server/kibana.jsonc | 5 + .../core-feature-flags-server/package.json | 6 + .../src/contracts.ts | 165 ++++++++++ .../src/feature_flag_definition.ts | 60 ++++ .../src/request_handler_context.ts | 18 ++ .../core-feature-flags-server/tsconfig.json | 16 + .../src/core_route_handler_context.ts | 11 + .../core_route_handler_context_params.mock.ts | 2 + .../tsconfig.json | 3 + .../src/preboot_request_handler_context.ts | 12 + .../src/request_handler_context.ts | 26 ++ .../tsconfig.json | 3 +- .../src/injected_metadata_service.test.ts | 27 ++ .../src/injected_metadata_service.ts | 4 + .../src/types.ts | 5 + .../src/injected_metadata_service.mock.ts | 1 + .../src/types.ts | 3 + .../src/internal_core_setup.ts | 2 + .../src/internal_core_start.ts | 2 + .../tsconfig.json | 3 +- .../src/core_setup.mock.ts | 2 + .../src/core_start.mock.ts | 2 + .../tsconfig.json | 3 +- .../core-lifecycle-browser/src/core_setup.ts | 3 + .../core-lifecycle-browser/src/core_start.ts | 3 + .../core-lifecycle-browser/tsconfig.json | 3 +- .../src/internal_core_setup.ts | 2 + .../src/internal_core_start.ts | 2 + .../tsconfig.json | 4 +- .../src/core_setup.mock.ts | 2 + .../src/core_start.mock.ts | 2 + .../src/internal_core_setup.mock.ts | 2 + .../src/internal_core_start.mock.ts | 2 + .../core-lifecycle-server-mocks/tsconfig.json | 1 + .../core-lifecycle-server/src/core_setup.ts | 3 + .../core-lifecycle-server/src/core_start.ts | 3 + .../core-lifecycle-server/tsconfig.json | 3 +- .../src/plugin_context.ts | 2 + .../src/plugin_context.ts | 13 + .../rendering_service.test.ts.snap | 217 +++++++++++++ .../src/rendering_service.test.ts | 17 + .../src/rendering_service.tsx | 18 +- .../src/test_helpers/params.ts | 2 + .../src/types.ts | 2 + .../tsconfig.json | 2 + .../src/core_system.ts | 9 + .../core-root-browser-internal/tsconfig.json | 1 + .../src/register_service_config.ts | 2 + .../core-root-server-internal/src/server.ts | 10 + .../core-root-server-internal/tsconfig.json | 1 + packages/kbn-optimizer/limits.yml | 6 +- renovate.json | 17 +- src/core/public/index.ts | 5 + src/core/public/mocks.ts | 1 + src/core/server/index.ts | 5 + .../config/check_dynamic_config.test.ts | 2 + src/core/server/mocks.ts | 3 + src/core/tsconfig.json | 4 + src/plugins/home/kibana.jsonc | 3 +- .../test_suites/core_plugins/rendering.ts | 2 - tsconfig.base.json | 14 + x-pack/plugins/cloud/server/mocks.ts | 2 + .../cloud_chat/kibana.jsonc | 1 - .../cloud_chat/server/plugin.ts | 31 +- .../cloud_chat/server/routes/chat.test.ts | 26 +- .../cloud_chat/server/routes/chat.ts | 18 +- .../cloud_chat/tsconfig.json | 1 - .../cloud_experiments/README.mdx | 187 ++--------- .../common/constants.test.ts | 25 -- .../cloud_experiments/common/constants.ts | 51 --- .../cloud_experiments/common/index.ts | 13 - .../common/metadata_service/index.ts | 1 + .../metadata_service/initialize_metadata.ts | 63 ++++ .../metadata_service/metadata_service.test.ts | 85 +++-- .../metadata_service/metadata_service.ts | 129 ++++++-- .../metadata_service/remove_undefined.ts | 16 + .../cloud_experiments/common/mocks.ts | 19 -- .../cloud_experiments/common/types.ts | 74 ----- .../cloud_experiments/kibana.jsonc | 4 +- .../public/launch_darkly_client/index.ts | 12 - .../launch_darkly_client.test.mock.ts | 27 -- .../launch_darkly_client.test.ts | 195 ------------ .../launch_darkly_client.ts | 104 ------- .../cloud_experiments/public/plugin.test.ts | 254 ++------------- .../cloud_experiments/public/plugin.ts | 147 ++++----- .../cloud_experiments/server/config.test.ts | 81 +---- .../cloud_experiments/server/config.ts | 2 - .../server/launch_darkly_client/index.ts | 8 - .../launch_darkly_client.test.mock.ts | 25 -- .../launch_darkly_client.test.ts | 217 ------------- .../launch_darkly_client.ts | 97 ------ .../server/launch_darkly_client/mocks.ts | 26 -- .../cloud_experiments/server/plugin.test.ts | 250 +++------------ .../cloud_experiments/server/plugin.ts | 130 +++----- .../usage/register_usage_collector.test.ts | 22 +- .../server/usage/register_usage_collector.ts | 23 +- .../cloud_experiments/tsconfig.json | 6 +- .../fleet/.storybook/context/index.tsx | 2 + .../observability_onboarding/README.md | 11 +- .../observability_onboarding/kibana.jsonc | 2 +- .../observability_onboarding/public/plugin.ts | 2 - .../observability_onboarding/tsconfig.json | 1 - x-pack/plugins/security_solution/kibana.jsonc | 1 - .../common/lib/kibana/kibana_react.mock.ts | 3 - .../plugins/security_solution/public/types.ts | 2 - .../plugins/security_solution/tsconfig.json | 1 - yarn.lock | 100 +++--- 164 files changed, 3607 insertions(+), 1943 deletions(-) create mode 100755 examples/feature_flags_example/README.md create mode 100644 examples/feature_flags_example/common/feature_flags.ts create mode 100644 examples/feature_flags_example/common/index.ts create mode 100644 examples/feature_flags_example/kibana.jsonc create mode 100644 examples/feature_flags_example/public/application.tsx create mode 100644 examples/feature_flags_example/public/components/app.tsx create mode 100644 examples/feature_flags_example/public/index.ts create mode 100644 examples/feature_flags_example/public/plugin.ts create mode 100644 examples/feature_flags_example/public/types.ts create mode 100644 examples/feature_flags_example/server/index.ts create mode 100644 examples/feature_flags_example/server/plugin.ts create mode 100644 examples/feature_flags_example/server/routes/index.ts create mode 100644 examples/feature_flags_example/tsconfig.json create mode 100644 packages/core/feature-flags/README.mdx create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/jest.config.js create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/jest.config.js create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-browser/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-browser/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-browser/src/types.ts create mode 100644 packages/core/feature-flags/core-feature-flags-browser/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/jest.config.js create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_request_handler_context.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/jest.config.js create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json create mode 100644 packages/core/feature-flags/core-feature-flags-server/README.md create mode 100644 packages/core/feature-flags/core-feature-flags-server/index.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server/kibana.jsonc create mode 100644 packages/core/feature-flags/core-feature-flags-server/package.json create mode 100644 packages/core/feature-flags/core-feature-flags-server/src/contracts.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server/src/feature_flag_definition.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server/tsconfig.json delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.test.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts delete mode 100755 x-pack/plugins/cloud_integrations/cloud_experiments/common/index.ts create mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/initialize_metadata.ts create mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/remove_undefined.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/common/mocks.ts delete mode 100755 x-pack/plugins/cloud_integrations/cloud_experiments/common/types.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/index.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.mock.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/index.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.mock.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts delete mode 100644 x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/mocks.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 76dccf659fb542..9284e5ba821f9b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/dev_docs/nav-kibana-dev.docnav.json b/dev_docs/nav-kibana-dev.docnav.json index 2a9893898e8c5b..dc8a4373f5450a 100644 --- a/dev_docs/nav-kibana-dev.docnav.json +++ b/dev_docs/nav-kibana-dev.docnav.json @@ -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 @@ ] } ] -} \ No newline at end of file +} diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 7e31cd50aeb815..38fc136ef1facc 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -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 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] diff --git a/examples/feature_flags_example/README.md b/examples/feature_flags_example/README.md new file mode 100755 index 00000000000000..54ecd4126683d5 --- /dev/null +++ b/examples/feature_flags_example/README.md @@ -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. diff --git a/examples/feature_flags_example/common/feature_flags.ts b/examples/feature_flags_example/common/feature_flags.ts new file mode 100644 index 00000000000000..fcff25bbd2c424 --- /dev/null +++ b/examples/feature_flags_example/common/feature_flags.ts @@ -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'; diff --git a/examples/feature_flags_example/common/index.ts b/examples/feature_flags_example/common/index.ts new file mode 100644 index 00000000000000..37bde8e9843e16 --- /dev/null +++ b/examples/feature_flags_example/common/index.ts @@ -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'; diff --git a/examples/feature_flags_example/kibana.jsonc b/examples/feature_flags_example/kibana.jsonc new file mode 100644 index 00000000000000..c2a855723bdac7 --- /dev/null +++ b/examples/feature_flags_example/kibana.jsonc @@ -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": [] + } +} diff --git a/examples/feature_flags_example/public/application.tsx b/examples/feature_flags_example/public/application.tsx new file mode 100644 index 00000000000000..eab558d9301bdc --- /dev/null +++ b/examples/feature_flags_example/public/application.tsx @@ -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( + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/feature_flags_example/public/components/app.tsx b/examples/feature_flags_example/public/components/app.tsx new file mode 100644 index 00000000000000..432e7dc348abc2 --- /dev/null +++ b/examples/feature_flags_example/public/components/app.tsx @@ -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 ( + <> + + + +

{PLUGIN_NAME}

+
+
+ + +

Demo of the feature flags service

+
+ +

+ To learn more, refer to{' '} + + the docs + + . +

+ + +

+ The feature flags are: + + + +

+
+ +

+ The observed feature flags are: + + + +

+
+
+
+
+ + ); +}; diff --git a/examples/feature_flags_example/public/index.ts b/examples/feature_flags_example/public/index.ts new file mode 100644 index 00000000000000..9324fbb56bc2ee --- /dev/null +++ b/examples/feature_flags_example/public/index.ts @@ -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(); +} diff --git a/examples/feature_flags_example/public/plugin.ts b/examples/feature_flags_example/public/plugin.ts new file mode 100644 index 00000000000000..915c40dcaafe87 --- /dev/null +++ b/examples/feature_flags_example/public/plugin.ts @@ -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() {} +} diff --git a/examples/feature_flags_example/public/types.ts b/examples/feature_flags_example/public/types.ts new file mode 100644 index 00000000000000..7f3f7107a13856 --- /dev/null +++ b/examples/feature_flags_example/public/types.ts @@ -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; +} diff --git a/examples/feature_flags_example/server/index.ts b/examples/feature_flags_example/server/index.ts new file mode 100644 index 00000000000000..ad88372a7e11e1 --- /dev/null +++ b/examples/feature_flags_example/server/index.ts @@ -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); +} diff --git a/examples/feature_flags_example/server/plugin.ts b/examples/feature_flags_example/server/plugin.ts new file mode 100644 index 00000000000000..3abd4554eb335c --- /dev/null +++ b/examples/feature_flags_example/server/plugin.ts @@ -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() {} +} diff --git a/examples/feature_flags_example/server/routes/index.ts b/examples/feature_flags_example/server/routes/index.ts new file mode 100644 index 00000000000000..97ce19ec9981b0 --- /dev/null +++ b/examples/feature_flags_example/server/routes/index.ts @@ -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), + }, + }); + } + ); +} diff --git a/examples/feature_flags_example/tsconfig.json b/examples/feature_flags_example/tsconfig.json new file mode 100644 index 00000000000000..bbd68332f3d377 --- /dev/null +++ b/examples/feature_flags_example/tsconfig.json @@ -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", + ] +} diff --git a/package.json b/package.json index 49b45c263c0206..d675146586e1f9 100644 --- a/package.json +++ b/package.json @@ -287,6 +287,12 @@ "@kbn/core-execution-context-server-internal": "link:packages/core/execution-context/core-execution-context-server-internal", "@kbn/core-fatal-errors-browser": "link:packages/core/fatal-errors/core-fatal-errors-browser", "@kbn/core-fatal-errors-browser-internal": "link:packages/core/fatal-errors/core-fatal-errors-browser-internal", + "@kbn/core-feature-flags-browser": "link:packages/core/feature-flags/core-feature-flags-browser", + "@kbn/core-feature-flags-browser-internal": "link:packages/core/feature-flags/core-feature-flags-browser-internal", + "@kbn/core-feature-flags-browser-mocks": "link:packages/core/feature-flags/core-feature-flags-browser-mocks", + "@kbn/core-feature-flags-server": "link:packages/core/feature-flags/core-feature-flags-server", + "@kbn/core-feature-flags-server-internal": "link:packages/core/feature-flags/core-feature-flags-server-internal", + "@kbn/core-feature-flags-server-mocks": "link:packages/core/feature-flags/core-feature-flags-server-mocks", "@kbn/core-history-block-plugin": "link:test/plugin_functional/plugins/core_history_block", "@kbn/core-http-browser": "link:packages/core/http/core-http-browser", "@kbn/core-http-browser-internal": "link:packages/core/http/core-http-browser-internal", @@ -505,6 +511,7 @@ "@kbn/expressions-explorer-plugin": "link:examples/expressions_explorer", "@kbn/expressions-plugin": "link:src/plugins/expressions", "@kbn/feature-controls-examples-plugin": "link:examples/feature_control_examples", + "@kbn/feature-flags-example-plugin": "link:examples/feature_flags_example", "@kbn/feature-usage-test-plugin": "link:x-pack/test/plugin_api_integration/plugins/feature_usage_test", "@kbn/features-plugin": "link:x-pack/plugins/features", "@kbn/fec-alerts-test-plugin": "link:x-pack/test/functional_execution_context/plugins/alerts", @@ -988,6 +995,7 @@ "@langchain/openai": "^0.1.3", "@langtrase/trace-attributes": "^3.0.8", "@launchdarkly/node-server-sdk": "^9.5.4", + "@launchdarkly/openfeature-node-server": "^1.0.0", "@loaders.gl/core": "^3.4.7", "@loaders.gl/json": "^3.4.7", "@loaders.gl/shapefile": "^3.4.7", @@ -996,6 +1004,10 @@ "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mapbox/mapbox-gl-supported": "2.0.1", "@mapbox/vector-tile": "1.3.1", + "@openfeature/core": "^1.3.0", + "@openfeature/launchdarkly-client-provider": "^0.3.0", + "@openfeature/server-sdk": "^1.15.0", + "@openfeature/web-sdk": "^1.2.1", "@opentelemetry/api": "^1.1.0", "@opentelemetry/api-metrics": "^0.31.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.34.0", @@ -1129,7 +1141,6 @@ "langchain": "^0.2.11", "langsmith": "^0.1.39", "launchdarkly-js-client-sdk": "^3.4.0", - "launchdarkly-node-server-sdk": "^7.0.3", "load-json-file": "^6.2.0", "lodash": "^4.17.21", "lru-cache": "^4.1.5", diff --git a/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_route_handler_context.ts b/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_route_handler_context.ts index b4be404f137af8..ce45e5c11833c9 100644 --- a/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_route_handler_context.ts +++ b/packages/core/elasticsearch/core-elasticsearch-server-internal/src/elasticsearch_route_handler_context.ts @@ -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 { diff --git a/packages/core/feature-flags/README.mdx b/packages/core/feature-flags/README.mdx new file mode 100644 index 00000000000000..d1e3583aaf2b8a --- /dev/null +++ b/packages/core/feature-flags/README.mdx @@ -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 +// /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 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" + } +} +``` diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/README.md b/packages/core/feature-flags/core-feature-flags-browser-internal/README.md new file mode 100644 index 00000000000000..f5696d4530483c --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/README.md @@ -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. \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts b/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts new file mode 100644 index 00000000000000..e22aeeecd35fb5 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts @@ -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'; diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/jest.config.js b/packages/core/feature-flags/core-feature-flags-browser-internal/jest.config.js new file mode 100644 index 00000000000000..be4a9c1b140730 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/jest.config.js @@ -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: ['/packages/core/feature-flags/core-feature-flags-browser-internal'], +}; diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc new file mode 100644 index 00000000000000..150509b99f519e --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-feature-flags-browser-internal", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/package.json b/packages/core/feature-flags/core-feature-flags-browser-internal/package.json new file mode 100644 index 00000000000000..de82d53b1c964c --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts new file mode 100644 index 00000000000000..596d64c7b77aef --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts @@ -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) { + 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; + + 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); + }); + }); +}); diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts new file mode 100644 index 00000000000000..0f7e572ef5ce04 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts @@ -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; + private context: MultiContextEvaluationContext = { kind: 'multi' }; + private overrides: Record = {}; + + /** + * 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 { + const featureFlagsChanged$ = new Subject(); + 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: (flagName: string, fallbackValue: Value) => + this.evaluateFlag(this.featureFlagsClient.getStringValue, flagName, fallbackValue), + getNumberValue: (flagName: string, fallbackValue: Value) => + this.evaluateFlag(this.featureFlagsClient.getNumberValue, flagName, fallbackValue), + getBooleanValue$: (flagName, fallbackValue) => { + return observeFeatureFlag$(flagName).pipe( + map(() => + this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue) + ) + ); + }, + getStringValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + map(() => + this.evaluateFlag( + this.featureFlagsClient.getStringValue, + flagName, + fallbackValue + ) + ) + ); + }, + getNumberValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + map(() => + this.evaluateFlag( + 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( + 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 { + // 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); + } +} diff --git a/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json b/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json new file mode 100644 index 00000000000000..3ed73d73e75a31 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json @@ -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", + ] +} diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/README.md b/packages/core/feature-flags/core-feature-flags-browser-mocks/README.md new file mode 100644 index 00000000000000..db756eddf2f1a5 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/README.md @@ -0,0 +1,3 @@ +# @kbn/core-feature-flags-browser-mocks + +Browser-side Jest mocks for the Feature Flags Service. diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts b/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts new file mode 100644 index 00000000000000..ad8cdae6a5ef1d --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts @@ -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 => { + return { + setProvider: jest.fn(), + appendContext: jest.fn().mockImplementation(Promise.resolve), + }; +}; + +const createFeatureFlagsStart = (): jest.Mocked => { + 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> => { + 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, +}; diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/jest.config.js b/packages/core/feature-flags/core-feature-flags-browser-mocks/jest.config.js new file mode 100644 index 00000000000000..f259faecb60465 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/jest.config.js @@ -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: ['/packages/core/feature-flags/core-feature-flags-browser-mocks'], +}; diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc new file mode 100644 index 00000000000000..0917a098841c48 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-feature-flags-browser-mocks", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/package.json b/packages/core/feature-flags/core-feature-flags-browser-mocks/package.json new file mode 100644 index 00000000000000..77e9150ce78341 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json b/packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json new file mode 100644 index 00000000000000..b7d1b3ca28cbb2 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json @@ -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", + ] +} diff --git a/packages/core/feature-flags/core-feature-flags-browser/README.md b/packages/core/feature-flags/core-feature-flags-browser/README.md new file mode 100644 index 00000000000000..5a6743adc5a094 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/README.md @@ -0,0 +1,3 @@ +# @kbn/core-feature-flags-browser + +Browser-side type definitions for the Feature Flags Service. diff --git a/packages/core/feature-flags/core-feature-flags-browser/index.ts b/packages/core/feature-flags/core-feature-flags-browser/index.ts new file mode 100644 index 00000000000000..6c79c96f01878a --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/index.ts @@ -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'; diff --git a/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc new file mode 100644 index 00000000000000..56187119509b91 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-feature-flags-browser", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-browser/package.json b/packages/core/feature-flags/core-feature-flags-browser/package.json new file mode 100644 index 00000000000000..235f52c0521f1d --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-browser/src/types.ts b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts new file mode 100644 index 00000000000000..844675aab4603e --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts @@ -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; +} + +/** + * 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; + + /** + * 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(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(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; + + /** + * 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$(flagName: string, fallbackValue: Value): Observable; + + /** + * 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$(flagName: string, fallbackValue: Value): Observable; +} diff --git a/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json b/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json new file mode 100644 index 00000000000000..9fa73d55be7706 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/README.md b/packages/core/feature-flags/core-feature-flags-server-internal/README.md new file mode 100644 index 00000000000000..288d47fdc95eb8 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/README.md @@ -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. diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/index.ts b/packages/core/feature-flags/core-feature-flags-server-internal/index.ts new file mode 100644 index 00000000000000..97083327e609d8 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/index.ts @@ -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'; diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/jest.config.js b/packages/core/feature-flags/core-feature-flags-server-internal/jest.config.js new file mode 100644 index 00000000000000..67b65d2040c540 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/jest.config.js @@ -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: ['/packages/core/feature-flags/core-feature-flags-server-internal'], +}; diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc new file mode 100644 index 00000000000000..60a01597c04544 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/core-feature-flags-server-internal", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/package.json b/packages/core/feature-flags/core-feature-flags-server-internal/package.json new file mode 100644 index 00000000000000..33383b043fa5c6 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts new file mode 100644 index 00000000000000..fe6725456806b6 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts @@ -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; +} + +/** + * Config descriptor for the feature flags service + * @private + */ +export const featureFlagsConfig: ServiceConfigDescriptor = { + /** + * All config is prefixed by `feature_flags` + */ + path: 'feature_flags', + /** + * The definition of the validation config schema + */ + schema: configSchema, +}; diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_request_handler_context.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_request_handler_context.ts new file mode 100644 index 00000000000000..f0ac4da69b1a93 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_request_handler_context.ts @@ -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 { + return this.featureFlags.getBooleanValue(flagName, fallback); + } + + public getStringValue(flagName: string, fallback: Value): Promise { + return this.featureFlags.getStringValue(flagName, fallback); + } + + public getNumberValue(flagName: string, fallback: Value): Promise { + return this.featureFlags.getNumberValue(flagName, fallback); + } +} diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts new file mode 100644 index 00000000000000..7bad676b9528b8 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts @@ -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 }); + }); +}); diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts new file mode 100644 index 00000000000000..7b01ebde731fe5 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts @@ -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; +} + +/** + * The server-side Feature Flags Service + * @private + */ +export class FeatureFlagsService { + private readonly featureFlagsClient: Client; + private readonly logger: Logger; + private overrides: Record = {}; + 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.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(); + 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 (flagName: string, fallbackValue: Value) => + await this.evaluateFlag( + this.featureFlagsClient.getStringValue, + flagName, + fallbackValue + ), + getNumberValue: async (flagName: string, fallbackValue: Value) => + await this.evaluateFlag( + this.featureFlagsClient.getNumberValue, + flagName, + fallbackValue + ), + getBooleanValue$: (flagName, fallbackValue) => { + return observeFeatureFlag$(flagName).pipe( + switchMap(() => + this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue) + ) + ); + }, + getStringValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + switchMap(() => + this.evaluateFlag( + this.featureFlagsClient.getStringValue, + flagName, + fallbackValue + ) + ) + ); + }, + getNumberValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + switchMap(() => + this.evaluateFlag( + 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( + evaluationFn: (flagName: string, fallbackValue: T) => Promise, + flagName: string, + fallbackValue: T + ): Promise { + 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); + } +} diff --git a/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json b/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json new file mode 100644 index 00000000000000..72a97ef56eb4f8 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json @@ -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", + ] +} diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/README.md b/packages/core/feature-flags/core-feature-flags-server-mocks/README.md new file mode 100644 index 00000000000000..caf2c4a13f8fb1 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/README.md @@ -0,0 +1,3 @@ +# @kbn/core-feature-flags-server-mocks + +Server-side Jest mocks for the Feature Flags Service. diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts b/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts new file mode 100644 index 00000000000000..182f6dbc21102e --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts @@ -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 => { + return { + ...createFeatureFlagsSetup(), + getOverrides: jest.fn().mockReturnValue({}), + }; +}; + +const createFeatureFlagsSetup = (): jest.Mocked => { + return { + setProvider: jest.fn(), + appendContext: jest.fn(), + }; +}; + +const createFeatureFlagsStart = (): jest.Mocked => { + 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 => { + 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> => { + 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, +}; diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/jest.config.js b/packages/core/feature-flags/core-feature-flags-server-mocks/jest.config.js new file mode 100644 index 00000000000000..bc50c37548c95e --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/jest.config.js @@ -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: ['/packages/core/feature-flags/core-feature-flags-server-mocks'], +}; diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc new file mode 100644 index 00000000000000..69b03f0badbdc1 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/core-feature-flags-server-mocks", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/package.json b/packages/core/feature-flags/core-feature-flags-server-mocks/package.json new file mode 100644 index 00000000000000..f009e55f76a8e3 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json b/packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json new file mode 100644 index 00000000000000..c672eb28c83a91 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json @@ -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", + ] +} diff --git a/packages/core/feature-flags/core-feature-flags-server/README.md b/packages/core/feature-flags/core-feature-flags-server/README.md new file mode 100644 index 00000000000000..86b6fc210d0d43 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/README.md @@ -0,0 +1,3 @@ +# @kbn/core-feature-flags-server + +Server-side type definitions for the Feature Flags Service. diff --git a/packages/core/feature-flags/core-feature-flags-server/index.ts b/packages/core/feature-flags/core-feature-flags-server/index.ts new file mode 100644 index 00000000000000..7538b68686cd9a --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/index.ts @@ -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'; diff --git a/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc new file mode 100644 index 00000000000000..dc896ed83b97b4 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/core-feature-flags-server", + "owner": "@elastic/kibana-core" +} diff --git a/packages/core/feature-flags/core-feature-flags-server/package.json b/packages/core/feature-flags/core-feature-flags-server/package.json new file mode 100644 index 00000000000000..d1f18a98a38400 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts b/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts new file mode 100644 index 00000000000000..34fc3a3a73383c --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts @@ -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; + + /** + * 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(flagName: string, fallbackValue: Value): Promise; + + /** + * 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(flagName: string, fallbackValue: Value): Promise; + + /** + * 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; + + /** + * 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$(flagName: string, fallbackValue: Value): Observable; + + /** + * 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$(flagName: string, fallbackValue: Value): Observable; +} diff --git a/packages/core/feature-flags/core-feature-flags-server/src/feature_flag_definition.ts b/packages/core/feature-flags/core-feature-flags-server/src/feature_flag_definition.ts new file mode 100644 index 00000000000000..3ea761484fc2a2 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/src/feature_flag_definition.ts @@ -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 { + /** + * 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; + }>; +} diff --git a/packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.ts b/packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.ts new file mode 100644 index 00000000000000..25f521e18f1c91 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.ts @@ -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' +>; diff --git a/packages/core/feature-flags/core-feature-flags-server/tsconfig.json b/packages/core/feature-flags/core-feature-flags-server/tsconfig.json new file mode 100644 index 00000000000000..f5bb1b00512e4f --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/packages/core/http/core-http-request-handler-context-server-internal/src/core_route_handler_context.ts b/packages/core/http/core-http-request-handler-context-server-internal/src/core_route_handler_context.ts index 2e1e3b790b55a0..376eb5a2bd24fe 100644 --- a/packages/core/http/core-http-request-handler-context-server-internal/src/core_route_handler_context.ts +++ b/packages/core/http/core-http-request-handler-context-server-internal/src/core_route_handler_context.ts @@ -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( diff --git a/packages/core/http/core-http-request-handler-context-server-internal/src/test_helpers/core_route_handler_context_params.mock.ts b/packages/core/http/core-http-request-handler-context-server-internal/src/test_helpers/core_route_handler_context_params.mock.ts index 1839f51a68c62c..01b3c7aee46566 100644 --- a/packages/core/http/core-http-request-handler-context-server-internal/src/test_helpers/core_route_handler_context_params.mock.ts +++ b/packages/core/http/core-http-request-handler-context-server-internal/src/test_helpers/core_route_handler_context_params.mock.ts @@ -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(), diff --git a/packages/core/http/core-http-request-handler-context-server-internal/tsconfig.json b/packages/core/http/core-http-request-handler-context-server-internal/tsconfig.json index 9e5ab96901e868..99c86608d99de2 100644 --- a/packages/core/http/core-http-request-handler-context-server-internal/tsconfig.json +++ b/packages/core/http/core-http-request-handler-context-server-internal/tsconfig.json @@ -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/**/*", diff --git a/packages/core/http/core-http-request-handler-context-server/src/preboot_request_handler_context.ts b/packages/core/http/core-http-request-handler-context-server/src/preboot_request_handler_context.ts index 8f2755403e1d83..294adf380d8e17 100644 --- a/packages/core/http/core-http-request-handler-context-server/src/preboot_request_handler_context.ts +++ b/packages/core/http/core-http-request-handler-context-server/src/preboot_request_handler_context.ts @@ -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; } diff --git a/packages/core/http/core-http-request-handler-context-server/src/request_handler_context.ts b/packages/core/http/core-http-request-handler-context-server/src/request_handler_context.ts index 0dc5655c820fae..1d77e033b8e628 100644 --- a/packages/core/http/core-http-request-handler-context-server/src/request_handler_context.ts +++ b/packages/core/http/core-http-request-handler-context-server/src/request_handler_context.ts @@ -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; } diff --git a/packages/core/http/core-http-request-handler-context-server/tsconfig.json b/packages/core/http/core-http-request-handler-context-server/tsconfig.json index 4606770c753d7c..905a13801f2231 100644 --- a/packages/core/http/core-http-request-handler-context-server/tsconfig.json +++ b/packages/core/http/core-http-request-handler-context-server/tsconfig.json @@ -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/**/*", diff --git a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.test.ts b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.test.ts index dd1e768a647091..dd315b38fe3c20 100644 --- a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.test.ts +++ b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.test.ts @@ -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(); + }); +}); diff --git a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.ts b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.ts index 624c213ce11bcf..b9594b9f042e34 100644 --- a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.ts +++ b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/injected_metadata_service.ts @@ -95,6 +95,10 @@ export class InjectedMetadataService { getCustomBranding: () => { return this.state.customBranding; }, + + getFeatureFlags: () => { + return this.state.featureFlags; + }, }; } } diff --git a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts index f8c730414746d9..244b99da0c2073 100644 --- a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts +++ b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts @@ -58,6 +58,11 @@ export interface InternalInjectedMetadataSetup { }; }; getCustomBranding: () => CustomBranding; + getFeatureFlags: () => + | { + overrides: Record; + } + | undefined; } /** @internal */ diff --git a/packages/core/injected-metadata/core-injected-metadata-browser-mocks/src/injected_metadata_service.mock.ts b/packages/core/injected-metadata/core-injected-metadata-browser-mocks/src/injected_metadata_service.mock.ts index 9ee48eda092104..804134cabd4b97 100644 --- a/packages/core/injected-metadata/core-injected-metadata-browser-mocks/src/injected_metadata_service.mock.ts +++ b/packages/core/injected-metadata/core-injected-metadata-browser-mocks/src/injected_metadata_service.mock.ts @@ -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'); diff --git a/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts b/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts index 498e1ff0f15e9f..1ee75dbfc0d5d3 100644 --- a/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts +++ b/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts @@ -63,6 +63,9 @@ export interface InjectedMetadata { mode: EnvironmentMode; packageInfo: PackageInfo; }; + featureFlags?: { + overrides: Record; + }; anonymousStatusPage: boolean; i18n: { translationsUrl: string; diff --git a/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_setup.ts b/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_setup.ts index 9730936cedb935..07695d37bea690 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_setup.ts @@ -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; diff --git a/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_start.ts b/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_start.ts index 0571ca18955b1c..f422275f530196 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_start.ts +++ b/packages/core/lifecycle/core-lifecycle-browser-internal/src/internal_core_start.ts @@ -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 { application: InternalApplicationStart; + featureFlags: FeatureFlagsStart; injectedMetadata: InternalInjectedMetadataStart; http: InternalHttpStart; security: InternalSecurityServiceStart; diff --git a/packages/core/lifecycle/core-lifecycle-browser-internal/tsconfig.json b/packages/core/lifecycle/core-lifecycle-browser-internal/tsconfig.json index 4fd531018418da..84c55c0e87b90f 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-internal/tsconfig.json +++ b/packages/core/lifecycle/core-lifecycle-browser-internal/tsconfig.json @@ -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/**/*", diff --git a/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_setup.mock.ts b/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_setup.mock.ts index 1a3f6e22ff39a9..9937286104efec 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_setup.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_setup.mock.ts @@ -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, any, any]>, []>(() => Promise.resolve([createCoreStartMock({ basePath }), pluginStartDeps, pluginStartContract]) ), diff --git a/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_start.mock.ts b/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_start.mock.ts index 71f5dd0db3958b..f6f5aa5493f73c 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_start.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-browser-mocks/src/core_start.mock.ts @@ -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(), diff --git a/packages/core/lifecycle/core-lifecycle-browser-mocks/tsconfig.json b/packages/core/lifecycle/core-lifecycle-browser-mocks/tsconfig.json index b6df8220f8603a..cc1f0ed785dbcb 100644 --- a/packages/core/lifecycle/core-lifecycle-browser-mocks/tsconfig.json +++ b/packages/core/lifecycle/core-lifecycle-browser-mocks/tsconfig.json @@ -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/**/*", diff --git a/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts b/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts index a989bdda604267..bef46aa4b84c1f 100644 --- a/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts @@ -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 & { elasticsearch: ReturnType; @@ -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(), diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_start.mock.ts b/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_start.mock.ts index 8bbdb322bb14af..d4b341bcf2f54e 100644 --- a/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_start.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_start.mock.ts @@ -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 = { @@ -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(), diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_setup.mock.ts b/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_setup.mock.ts index 4919fa0c65eb5b..4e6ca5b75059f7 100644 --- a/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_setup.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_setup.mock.ts @@ -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(), diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_start.mock.ts b/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_start.mock.ts index 4e0d63f654516a..248a9712057f4b 100644 --- a/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_start.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-server-mocks/src/internal_core_start.mock.ts @@ -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(), diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/tsconfig.json b/packages/core/lifecycle/core-lifecycle-server-mocks/tsconfig.json index bacda3278557bc..89ec5b0e1b7ba3 100644 --- a/packages/core/lifecycle/core-lifecycle-server-mocks/tsconfig.json +++ b/packages/core/lifecycle/core-lifecycle-server-mocks/tsconfig.json @@ -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/**/*", diff --git a/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts b/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts index 63f56f22cc1453..59616a5d65ecd0 100644 --- a/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts @@ -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 & { /** {@link HttpResources} */ diff --git a/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts b/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts index ae6135c55800ff..13d871df8b7597 100644 --- a/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts +++ b/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts @@ -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} */ diff --git a/packages/core/lifecycle/core-lifecycle-server/tsconfig.json b/packages/core/lifecycle/core-lifecycle-server/tsconfig.json index ed35724914dec8..c8b95eed1e6d7e 100644 --- a/packages/core/lifecycle/core-lifecycle-server/tsconfig.json +++ b/packages/core/lifecycle/core-lifecycle-server/tsconfig.json @@ -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/**/*", diff --git a/packages/core/plugins/core-plugins-browser-internal/src/plugin_context.ts b/packages/core/plugins/core-plugins-browser-internal/src/plugin_context.ts index ad242905f77516..b78e5cec0b2768 100644 --- a/packages/core/plugins/core-plugins-browser-internal/src/plugin_context.ts +++ b/packages/core/plugins/core-plugins-browser-internal/src/plugin_context.ts @@ -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: { diff --git a/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts b/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts index 76306751427cf6..539b6299749828 100644 --- a/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts +++ b/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts @@ -218,6 +218,10 @@ export function createPluginSetupContext({ 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({ 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, diff --git a/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap b/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap index e92e760b400e5f..c858b6a8470d2e 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap +++ b/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap @@ -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, + "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, +} +`; + +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, + "buildNumber": Any, + "clusterInfo": Object {}, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "customBranding": Object {}, + "env": Object { + "mode": Object { + "dev": Any, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildDate": "2023-05-15T23:12:09.000Z", + "buildFlavor": Any, + "buildNum": Any, + "buildSha": Any, + "buildShaShort": "XXXXXX", + "dist": Any, + "version": Any, + }, + }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, + "featureFlags": Object { + "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, + "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, +} +`; + +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, + "buildNumber": Any, + "clusterInfo": Object { + "cluster_build_flavor": "default", + "cluster_name": "cluster-name", + "cluster_uuid": "cluster-uuid", + "cluster_version": "8.0.0", + }, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "customBranding": Object {}, + "env": Object { + "mode": Object { + "dev": Any, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildDate": "2023-05-15T23:12:09.000Z", + "buildFlavor": Any, + "buildNum": Any, + "buildSha": Any, + "buildShaShort": "XXXXXX", + "dist": Any, + "version": Any, + }, + }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, + "featureFlags": Object { + "overrides": Object { + "my-overridden-flag": 1234, + }, + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/MOCK_HASH/en.json", }, diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts index 1a746e1dbd7847..b22697a4947881 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts @@ -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: { diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx index 25a10be54f5bd9..a6963284758531 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx @@ -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, diff --git a/packages/core/rendering/core-rendering-server-internal/src/test_helpers/params.ts b/packages/core/rendering/core-rendering-server-internal/src/test_helpers/params.ts index b8a3f1fe0c35b7..735358f5aa92fe 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/test_helpers/params.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/test_helpers/params.ts @@ -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, diff --git a/packages/core/rendering/core-rendering-server-internal/src/types.ts b/packages/core/rendering/core-rendering-server-internal/src/types.ts index 57fee5e26cf489..1897ffdc08eb38 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/types.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/types.ts @@ -24,6 +24,7 @@ import type { CustomBranding } from '@kbn/core-custom-branding-common'; import type { InternalUserSettingsServiceSetup } from '@kbn/core-user-settings-server-internal'; import type { I18nServiceSetup } from '@kbn/core-i18n-server'; import type { InternalI18nServicePreboot } from '@kbn/core-i18n-server-internal'; +import type { InternalFeatureFlagsSetup } from '@kbn/core-feature-flags-server-internal'; /** @internal */ export interface RenderingMetadata { @@ -49,6 +50,7 @@ export interface RenderingPrebootDeps { /** @internal */ export interface RenderingSetupDeps { elasticsearch: InternalElasticsearchServiceSetup; + featureFlags: InternalFeatureFlagsSetup; http: InternalHttpServiceSetup; status: InternalStatusServiceSetup; uiPlugins: UiPlugins; diff --git a/packages/core/rendering/core-rendering-server-internal/tsconfig.json b/packages/core/rendering/core-rendering-server-internal/tsconfig.json index 2689069f79d799..28a22d3d51ca90 100644 --- a/packages/core/rendering/core-rendering-server-internal/tsconfig.json +++ b/packages/core/rendering/core-rendering-server-internal/tsconfig.json @@ -45,6 +45,8 @@ "@kbn/core-i18n-server-internal", "@kbn/core-i18n-server-mocks", "@kbn/apm-config-loader", + "@kbn/core-feature-flags-server-internal", + "@kbn/core-feature-flags-server-mocks", ], "exclude": [ "target/**/*", diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts index 8428333b966869..44e25b257e32cd 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.ts @@ -22,6 +22,7 @@ import { I18nService } from '@kbn/core-i18n-browser-internal'; import { ExecutionContextService } from '@kbn/core-execution-context-browser-internal'; import type { FatalErrorsSetup } from '@kbn/core-fatal-errors-browser'; import { FatalErrorsService } from '@kbn/core-fatal-errors-browser-internal'; +import { FeatureFlagsService } from '@kbn/core-feature-flags-browser-internal'; import { HttpService } from '@kbn/core-http-browser-internal'; import { SettingsService, UiSettingsService } from '@kbn/core-ui-settings-browser-internal'; import { DeprecationsService } from '@kbn/core-deprecations-browser-internal'; @@ -85,6 +86,7 @@ export class CoreSystem { private readonly loggingSystem: BrowserLoggingSystem; private readonly analytics: AnalyticsService; private readonly fatalErrors: FatalErrorsService; + private readonly featureFlags: FeatureFlagsService; private readonly injectedMetadata: InjectedMetadataService; private readonly notifications: NotificationsService; private readonly http: HttpService; @@ -132,6 +134,7 @@ export class CoreSystem { // Stop Core before rendering any fatal errors into the DOM this.stop(); }); + this.featureFlags = new FeatureFlagsService(this.coreContext); this.security = new SecurityService(this.coreContext); this.userProfile = new UserProfileService(this.coreContext); this.theme = new ThemeService(); @@ -251,11 +254,13 @@ export class CoreSystem { const application = this.application.setup({ http, analytics }); this.coreApp.setup({ application, http, injectedMetadata, notifications }); + const featureFlags = this.featureFlags.setup({ injectedMetadata }); const core: InternalCoreSetup = { analytics, application, fatalErrors: this.fatalErrorsSetup, + featureFlags, http, injectedMetadata, notifications, @@ -357,12 +362,15 @@ export class CoreSystem { theme, }); + const featureFlags = await this.featureFlags.start(); + const core: InternalCoreStart = { analytics, application, chrome, docLinks, executionContext, + featureFlags, http, theme, savedObjects, @@ -439,6 +447,7 @@ export class CoreSystem { this.deprecations.stop(); this.theme.stop(); this.analytics.stop(); + this.featureFlags.stop(); this.security.stop(); this.userProfile.stop(); this.rootDomElement.textContent = ''; diff --git a/packages/core/root/core-root-browser-internal/tsconfig.json b/packages/core/root/core-root-browser-internal/tsconfig.json index e576ecf8cf920e..a44a523d057445 100644 --- a/packages/core/root/core-root-browser-internal/tsconfig.json +++ b/packages/core/root/core-root-browser-internal/tsconfig.json @@ -67,6 +67,7 @@ "@kbn/core-user-profile-browser-mocks", "@kbn/core-user-profile-browser-internal", "@kbn/core-injected-metadata-common-internal", + "@kbn/core-feature-flags-browser-internal", ], "exclude": [ "target/**/*", diff --git a/packages/core/root/core-root-server-internal/src/register_service_config.ts b/packages/core/root/core-root-server-internal/src/register_service_config.ts index 3b131d721b4e83..ae38eba4c9ddce 100644 --- a/packages/core/root/core-root-server-internal/src/register_service_config.ts +++ b/packages/core/root/core-root-server-internal/src/register_service_config.ts @@ -33,6 +33,7 @@ import { config as deprecationConfig } from '@kbn/core-deprecations-server-inter import { statusConfig } from '@kbn/core-status-server-internal'; import { uiSettingsConfig } from '@kbn/core-ui-settings-server-internal'; import { config as pluginsConfig } from '@kbn/core-plugins-server-internal'; +import { featureFlagsConfig } from '@kbn/core-feature-flags-server-internal'; import { elasticApmConfig } from './root/elastic_config'; import { serverlessConfig } from './root/serverless_config'; import { coreConfig } from './core_config'; @@ -48,6 +49,7 @@ export function registerServiceConfig(configService: ConfigService) { coreAppConfig, elasticApmConfig, executionContextConfig, + featureFlagsConfig, externalUrlConfig, httpConfig, i18nConfig, diff --git a/packages/core/root/core-root-server-internal/src/server.ts b/packages/core/root/core-root-server-internal/src/server.ts index 64cf2c936e3be5..447db192c3048b 100644 --- a/packages/core/root/core-root-server-internal/src/server.ts +++ b/packages/core/root/core-root-server-internal/src/server.ts @@ -20,6 +20,7 @@ import { NodeService } from '@kbn/core-node-server-internal'; import { AnalyticsService } from '@kbn/core-analytics-server-internal'; import { EnvironmentService } from '@kbn/core-environment-server-internal'; import { ExecutionContextService } from '@kbn/core-execution-context-server-internal'; +import { FeatureFlagsService } from '@kbn/core-feature-flags-server-internal'; import { PrebootService } from '@kbn/core-preboot-server-internal'; import { ContextService } from '@kbn/core-http-context-server-internal'; import { HttpService } from '@kbn/core-http-server-internal'; @@ -69,6 +70,7 @@ export class Server { private readonly capabilities: CapabilitiesService; private readonly context: ContextService; private readonly elasticsearch: ElasticsearchService; + private readonly featureFlags: FeatureFlagsService; private readonly http: HttpService; private readonly rendering: RenderingService; private readonly log: Logger; @@ -118,6 +120,7 @@ export class Server { const core = { coreId, configService: this.configService, env, logger: this.logger }; this.analytics = new AnalyticsService(core); this.context = new ContextService(core); + this.featureFlags = new FeatureFlagsService(core); this.http = new HttpService(core); this.rendering = new RenderingService(core); this.plugins = new PluginsService(core); @@ -325,9 +328,11 @@ export class Server { const customBrandingSetup = this.customBranding.setup(); const userSettingsServiceSetup = this.userSettingsService.setup(); + const featureFlagsSetup = this.featureFlags.setup(); const renderingSetup = await this.rendering.setup({ elasticsearch: elasticsearchServiceSetup, + featureFlags: featureFlagsSetup, http: httpSetup, status: statusSetup, uiPlugins, @@ -352,6 +357,7 @@ export class Server { elasticsearch: elasticsearchServiceSetup, environment: environmentSetup, executionContext: executionContextSetup, + featureFlags: featureFlagsSetup, http: httpSetup, i18n: i18nServiceSetup, savedObjects: savedObjectsSetup, @@ -432,6 +438,8 @@ export class Server { exposedConfigsToUsage: this.plugins.getExposedPluginConfigsToUsage(), }); + const featureFlagsStart = this.featureFlags.start(); + this.status.start(); this.coreStart = { @@ -441,6 +449,7 @@ export class Server { docLinks: docLinkStart, elasticsearch: elasticsearchStart, executionContext: executionContextStart, + featureFlags: featureFlagsStart, http: httpStart, metrics: metricsStart, savedObjects: savedObjectsStart, @@ -484,6 +493,7 @@ export class Server { await this.status.stop(); await this.logging.stop(); await this.customBranding.stop(); + await this.featureFlags.stop(); this.node.stop(); this.deprecations.stop(); this.security.stop(); diff --git a/packages/core/root/core-root-server-internal/tsconfig.json b/packages/core/root/core-root-server-internal/tsconfig.json index 528e1aacc0a934..843a701db807bf 100644 --- a/packages/core/root/core-root-server-internal/tsconfig.json +++ b/packages/core/root/core-root-server-internal/tsconfig.json @@ -76,6 +76,7 @@ "@kbn/core-usage-data-server-mocks", "@kbn/core-user-profile-server-mocks", "@kbn/core-user-profile-server-internal", + "@kbn/core-feature-flags-server-internal", ], "exclude": [ "target/**/*", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index a7d94a423d606e..116a087af89f67 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -14,14 +14,14 @@ pageLoadAssetSize: cloudChat: 19894 cloudDataMigration: 19170 cloudDefend: 18697 - cloudExperiments: 59358 + cloudExperiments: 109746 cloudFullStory: 18493 cloudLinks: 55984 cloudSecurityPosture: 19109 console: 46091 contentManagement: 16254 controls: 60000 - core: 435325 + core: 564663 crossClusterReplication: 65408 customIntegrations: 22034 dashboard: 52967 @@ -159,7 +159,7 @@ pageLoadAssetSize: spaces: 57868 stackAlerts: 58316 stackConnectors: 67227 - synthetics: 40958 + synthetics: 55971 telemetry: 51957 telemetryManagementSection: 38586 threatIntelligence: 44299 diff --git a/renovate.json b/renovate.json index d013a49ea37bd7..eeb91efd871bf3 100644 --- a/renovate.json +++ b/renovate.json @@ -78,7 +78,22 @@ }, { "groupName": "LaunchDarkly", - "matchDepNames": ["launchdarkly-js-client-sdk", "@launchdarkly/node-server-sdk", "launchdarkly/find-code-references"], + "matchDepNames": [ + "launchdarkly-js-client-sdk", + "@openfeature/launchdarkly-client-provider", + "@launchdarkly/node-server-sdk", + "@launchdarkly/openfeature-node-server", + "launchdarkly/find-code-references" + ], + "reviewers": ["team:kibana-security", "team:kibana-core"], + "matchBaseBranches": ["main"], + "labels": ["release_note:skip", "Team:Security", "Team:Core", "backport:prev-minor"], + "minimumReleaseAge": "7 days", + "enabled": true + }, + { + "groupName": "OpenFeature", + "matchDepNames": ["@openfeature/core", "@openfeature/server-sdk", "@openfeature/web-sdk"], "reviewers": ["team:kibana-security", "team:kibana-core"], "matchBaseBranches": ["main"], "labels": ["release_note:skip", "Team:Security", "Team:Core", "backport:prev-minor"], diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 91320b8ade383b..ecce03d0b5092d 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -37,6 +37,11 @@ export type { FatalErrorsStart, FatalErrorInfo, } from '@kbn/core-fatal-errors-browser'; +export type { + EvaluationContext, + FeatureFlagsSetup, + FeatureFlagsStart, +} from '@kbn/core-feature-flags-browser'; export type { UiSettingsState, IUiSettingsClient, diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index dcd30a738d3d1b..61b9f7759c50f9 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -21,6 +21,7 @@ export { themeServiceMock } from '@kbn/core-theme-browser-mocks'; export { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; export { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; export { executionContextServiceMock } from '@kbn/core-execution-context-browser-mocks'; +export { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks'; export { fatalErrorsServiceMock } from '@kbn/core-fatal-errors-browser-mocks'; export { httpServiceMock } from '@kbn/core-http-browser-mocks'; export { i18nServiceMock } from '@kbn/core-i18n-browser-mocks'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index e684c9565d9edc..5282f2048dd069 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -71,6 +71,11 @@ export type { export type { KibanaExecutionContext } from '@kbn/core-execution-context-common'; export type { IExecutionContextContainer } from '@kbn/core-execution-context-server'; +export type { + EvaluationContext, + FeatureFlagsStart, + FeatureFlagsSetup, +} from '@kbn/core-feature-flags-server'; export type { Capabilities } from '@kbn/core-capabilities-common'; export type { CapabilitiesProvider, diff --git a/src/core/server/integration_tests/config/check_dynamic_config.test.ts b/src/core/server/integration_tests/config/check_dynamic_config.test.ts index 8cb9ac2466b62d..eaffd56ed1b163 100644 --- a/src/core/server/integration_tests/config/check_dynamic_config.test.ts +++ b/src/core/server/integration_tests/config/check_dynamic_config.test.ts @@ -129,6 +129,8 @@ describe('checking all opted-in dynamic config settings', () => { */ test('detecting all the settings that have opted-in for dynamic in-memory updates', () => { expect(getListOfDynamicConfigPaths()).toStrictEqual([ + // Making testing easier by having the ability of overriding the feature flags without the need to restart + 'feature_flags.overrides', // We need this for enriching our Perf tests with more valuable data regarding the steps of the test // Also helpful in Cloud & Serverless testing because we can't control the labels in those offerings 'telemetry.labels', diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 0356d5e4831036..0467b9c660db6f 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -23,6 +23,7 @@ import { coreLifecycleMock, coreInternalLifecycleMock } from '@kbn/core-lifecycl import { securityServiceMock } from '@kbn/core-security-server-mocks'; import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks'; import type { SharedGlobalConfig, PluginInitializerContext } from '@kbn/core-plugins-server'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; export { configServiceMock, configDeprecationsMock } from '@kbn/config-mocks'; export { loggingSystemMock } from '@kbn/core-logging-server-mocks'; @@ -46,6 +47,7 @@ export { deprecationsServiceMock } from '@kbn/core-deprecations-server-mocks'; export { coreUsageDataServiceMock } from '@kbn/core-usage-data-server-mocks'; export { i18nServiceMock } from '@kbn/core-i18n-server-mocks'; export { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks'; +export { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; export { docLinksServiceMock } from '@kbn/core-doc-links-server-mocks'; export { analyticsServiceMock } from '@kbn/core-analytics-server-mocks'; export { securityServiceMock } from '@kbn/core-security-server-mocks'; @@ -120,6 +122,7 @@ function pluginInitializerContextMock(config: T = {} as T) { function createCoreRequestHandlerContextMock() { return { + featureFlags: coreFeatureFlagsMock.createRequestHandlerContext(), savedObjects: { client: savedObjectsClientMock.create(), typeRegistry: savedObjectsTypeRegistryMock.create(), diff --git a/src/core/tsconfig.json b/src/core/tsconfig.json index 870d648d4b2e1f..92647e56fad825 100644 --- a/src/core/tsconfig.json +++ b/src/core/tsconfig.json @@ -169,6 +169,10 @@ "@kbn/core-user-profile-browser", "@kbn/core-metrics-server-internal", "@kbn/zod", + "@kbn/core-feature-flags-browser", + "@kbn/core-feature-flags-browser-mocks", + "@kbn/core-feature-flags-server", + "@kbn/core-feature-flags-server-mocks", ], "exclude": [ "target/**/*", diff --git a/src/plugins/home/kibana.jsonc b/src/plugins/home/kibana.jsonc index 33cb5c98e89db9..8c0a7884ce8ee0 100644 --- a/src/plugins/home/kibana.jsonc +++ b/src/plugins/home/kibana.jsonc @@ -12,8 +12,7 @@ "usageCollection", "customIntegrations", "cloud", - "guidedOnboarding", - "cloudExperiments" + "guidedOnboarding" ] } } diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index b9d2f51cc8c166..f1922eee52380c 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -235,8 +235,6 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.cloud.trial_end_date (string?)', 'xpack.cloud_integrations.chat.chatURL (string?)', 'xpack.cloud_integrations.chat.trialBuffer (number?)', - // No PII. This is an escape patch to override LaunchDarkly's flag resolution mechanism for testing or quick fix. - 'xpack.cloud_integrations.experiments.flag_overrides (record?)', // Commented because it's inside a schema conditional, and the test is not able to resolve it. But it's shared. // Added here for documentation purposes. // 'xpack.cloud_integrations.experiments.launch_darkly.client_id (string)', diff --git a/tsconfig.base.json b/tsconfig.base.json index ae8b6c2c2a95f3..1dd83354715826 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -360,6 +360,18 @@ "@kbn/core-fatal-errors-browser-internal/*": ["packages/core/fatal-errors/core-fatal-errors-browser-internal/*"], "@kbn/core-fatal-errors-browser-mocks": ["packages/core/fatal-errors/core-fatal-errors-browser-mocks"], "@kbn/core-fatal-errors-browser-mocks/*": ["packages/core/fatal-errors/core-fatal-errors-browser-mocks/*"], + "@kbn/core-feature-flags-browser": ["packages/core/feature-flags/core-feature-flags-browser"], + "@kbn/core-feature-flags-browser/*": ["packages/core/feature-flags/core-feature-flags-browser/*"], + "@kbn/core-feature-flags-browser-internal": ["packages/core/feature-flags/core-feature-flags-browser-internal"], + "@kbn/core-feature-flags-browser-internal/*": ["packages/core/feature-flags/core-feature-flags-browser-internal/*"], + "@kbn/core-feature-flags-browser-mocks": ["packages/core/feature-flags/core-feature-flags-browser-mocks"], + "@kbn/core-feature-flags-browser-mocks/*": ["packages/core/feature-flags/core-feature-flags-browser-mocks/*"], + "@kbn/core-feature-flags-server": ["packages/core/feature-flags/core-feature-flags-server"], + "@kbn/core-feature-flags-server/*": ["packages/core/feature-flags/core-feature-flags-server/*"], + "@kbn/core-feature-flags-server-internal": ["packages/core/feature-flags/core-feature-flags-server-internal"], + "@kbn/core-feature-flags-server-internal/*": ["packages/core/feature-flags/core-feature-flags-server-internal/*"], + "@kbn/core-feature-flags-server-mocks": ["packages/core/feature-flags/core-feature-flags-server-mocks"], + "@kbn/core-feature-flags-server-mocks/*": ["packages/core/feature-flags/core-feature-flags-server-mocks/*"], "@kbn/core-history-block-plugin": ["test/plugin_functional/plugins/core_history_block"], "@kbn/core-history-block-plugin/*": ["test/plugin_functional/plugins/core_history_block/*"], "@kbn/core-http-browser": ["packages/core/http/core-http-browser"], @@ -900,6 +912,8 @@ "@kbn/failed-test-reporter-cli/*": ["packages/kbn-failed-test-reporter-cli/*"], "@kbn/feature-controls-examples-plugin": ["examples/feature_control_examples"], "@kbn/feature-controls-examples-plugin/*": ["examples/feature_control_examples/*"], + "@kbn/feature-flags-example-plugin": ["examples/feature_flags_example"], + "@kbn/feature-flags-example-plugin/*": ["examples/feature_flags_example/*"], "@kbn/feature-usage-test-plugin": ["x-pack/test/plugin_api_integration/plugins/feature_usage_test"], "@kbn/feature-usage-test-plugin/*": ["x-pack/test/plugin_api_integration/plugins/feature_usage_test/*"], "@kbn/features-plugin": ["x-pack/plugins/features"], diff --git a/x-pack/plugins/cloud/server/mocks.ts b/x-pack/plugins/cloud/server/mocks.ts index e77f58902bf3e8..b54b21f5ce8277 100644 --- a/x-pack/plugins/cloud/server/mocks.ts +++ b/x-pack/plugins/cloud/server/mocks.ts @@ -18,6 +18,7 @@ function createSetupMock(): jest.Mocked { instanceSizeMb: 1234, isCloudEnabled: true, isElasticStaffOwned: true, + organizationId: 'organization-id', trialEndDate: new Date('2020-10-01T14:13:12Z'), projectsUrl: 'projects-url', baseUrl: 'base-url', @@ -31,6 +32,7 @@ function createSetupMock(): jest.Mocked { projectId: undefined, projectName: undefined, projectType: undefined, + orchestratorTarget: undefined, }, }; } diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc index 293d5f0baf3d7b..6394ccc7b53f1c 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc +++ b/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc @@ -18,7 +18,6 @@ "requiredBundles": [ ], "optionalPlugins": [ - "cloudExperiments" ] } } diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts index a708dd81cf5321..a2762b89e124a1 100755 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts @@ -8,20 +8,14 @@ import { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import { registerChatRoute } from './routes'; import type { CloudChatConfigType } from './config'; -import type { ChatVariant } from '../common/types'; interface CloudChatSetupDeps { cloud: CloudSetup; } -interface CloudChatStartDeps { - cloudExperiments?: CloudExperimentsPluginStart; -} - -export class CloudChatPlugin implements Plugin { +export class CloudChatPlugin implements Plugin { private readonly config: CloudChatConfigType; private readonly isDev: boolean; @@ -30,7 +24,7 @@ export class CloudChatPlugin implements Plugin, { cloud }: CloudChatSetupDeps) { + public setup(core: CoreSetup, { cloud }: CloudChatSetupDeps) { const { chatIdentitySecret, trialBuffer } = this.config; const { isCloudEnabled, trialEndDate } = cloud; @@ -41,27 +35,6 @@ export class CloudChatPlugin implements Plugin - core.getStartServices().then(([_, { cloudExperiments }]) => { - if (!cloudExperiments) { - return 'header'; - } else { - return cloudExperiments - .getVariation('cloud-chat.chat-variant', 'header') - .catch(() => 'header'); - } - }), - getChatDisabledThroughExperiments: () => - core.getStartServices().then(([_, { cloudExperiments }]) => { - if (!cloudExperiments) { - return false; - } else { - return cloudExperiments - .getVariation('cloud-chat.enabled', true) - .then((enabled) => !enabled) - .catch(() => false); - } - }), }); } } diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts index 94a55b2274a999..ea25ff9801af3b 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts @@ -16,20 +16,22 @@ import { httpServerMock, coreMock, securityServiceMock, + coreFeatureFlagsMock, } from '@kbn/core/server/mocks'; import { kibanaResponseFactory } from '@kbn/core/server'; import { type MetaWithSaml, registerChatRoute } from './chat'; -import { ChatVariant } from '../../common/types'; describe('chat route', () => { - const getChatVariant = async (): Promise => 'header'; - const getChatDisabledThroughExperiments = async (): Promise => false; let security: ReturnType; let requestHandlerContextMock: ReturnType; + let featureFlags: ReturnType; beforeEach(() => { const core = coreMock.createRequestHandlerContext(); security = core.security; + featureFlags = core.featureFlags; + featureFlags.getStringValue.mockResolvedValue('header'); + featureFlags.getBooleanValue.mockResolvedValue(true); requestHandlerContextMock = coreMock.createCustomRequestHandlerContext({ core }); }); @@ -43,8 +45,6 @@ describe('chat route', () => { chatIdentitySecret: 'secret', trialBuffer: 60, trialEndDate: new Date(), - getChatVariant, - getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; @@ -78,8 +78,6 @@ describe('chat route', () => { chatIdentitySecret: 'secret', trialBuffer: 60, trialEndDate: new Date(), - getChatVariant, - getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; @@ -120,8 +118,6 @@ describe('chat route', () => { isDev: false, chatIdentitySecret: 'secret', trialBuffer: 2, - getChatVariant, - getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; @@ -165,8 +161,6 @@ describe('chat route', () => { chatIdentitySecret: 'secret', trialBuffer: 2, trialEndDate, - getChatVariant, - getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; @@ -202,14 +196,13 @@ describe('chat route', () => { ); const router = httpServiceMock.createRouter(); + featureFlags.getBooleanValue.mockResolvedValueOnce(false); registerChatRoute({ router, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 60, trialEndDate: new Date(), - getChatVariant, - getChatDisabledThroughExperiments: async () => true, }); const [_config, handler] = router.get.mock.calls[0]; await expect( @@ -249,8 +242,6 @@ describe('chat route', () => { chatIdentitySecret: 'secret', trialBuffer: 60, trialEndDate: new Date(), - getChatVariant, - getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; await expect( @@ -297,8 +288,6 @@ describe('chat route', () => { chatIdentitySecret: 'secret', trialBuffer: 60, trialEndDate: new Date(), - getChatVariant, - getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; await expect( @@ -342,14 +331,13 @@ describe('chat route', () => { ); const router = httpServiceMock.createRouter(); + featureFlags.getStringValue.mockResolvedValueOnce('bubble'); registerChatRoute({ router, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 60, trialEndDate: new Date(), - getChatVariant: async () => 'bubble', - getChatDisabledThroughExperiments, }); const [_config, handler] = router.get.mock.calls[0]; await expect( diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts index 735a5db9298c40..e37ed1e935c49e 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts @@ -24,20 +24,12 @@ export const registerChatRoute = ({ trialEndDate, trialBuffer, isDev, - getChatVariant, - getChatDisabledThroughExperiments, }: { router: IRouter; chatIdentitySecret: string; trialEndDate?: Date; trialBuffer: number; isDev: boolean; - getChatVariant: () => Promise; - /** - * Returns true if chat is disabled in LaunchDarkly - * Meant to be used as a runtime kill switch - */ - getChatDisabledThroughExperiments: () => Promise; }) => { router.get( { @@ -45,7 +37,7 @@ export const registerChatRoute = ({ validate: {}, }, async (context, request, response) => { - const { security } = await context.core; + const { security, featureFlags } = await context.core; const user = security.authc.getCurrentUser(); if (!user) { @@ -85,7 +77,8 @@ export const registerChatRoute = ({ }); } - if (await getChatDisabledThroughExperiments()) { + // Meant to be used as a runtime kill switch via LaunchDarkly + if (!(await featureFlags.getBooleanValue('cloud-chat.enabled', true).catch(() => false))) { return response.badRequest({ body: 'Chat is disabled through experiments', }); @@ -96,7 +89,10 @@ export const registerChatRoute = ({ token, email: userEmail, id: userId, - chatVariant: await getChatVariant(), + chatVariant: await featureFlags.getStringValue( + 'cloud-chat.chat-variant', + 'header' + ), }; return response.ok({ body }); } diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json b/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json index ffa21f10a6b442..0dcc15f22cee5c 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json +++ b/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json @@ -18,7 +18,6 @@ "@kbn/i18n", "@kbn/config-schema", "@kbn/ui-theme", - "@kbn/cloud-experiments-plugin", "@kbn/react-kibana-context-render", "@kbn/logging", "@kbn/logging-mocks", diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx b/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx index 2dc4eb566210a7..6ef38ba1614af7 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx @@ -9,174 +9,39 @@ tags: ['kibana', 'dev', 'contributor', 'api docs', 'cloud', 'a/b testing', 'expe # Kibana Cloud Experiments Service -> [!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 for more information about how to interact with feature flags. -The Cloud Experiments Service provides the necessary APIs to implement A/B testing scenarios, fetching the variations in configuration and reporting back metrics to track conversion rates of the experiments. +This plugin takes care of instrumenting the LaunchDarkly feature flags provider, and registering it in the . +It also instantiates the most basic evaluation context that our segmentation rules can rely on. The `cloudExperiments` plugin is disabled by default and only enabled on Elastic Cloud deployments. -## Public API +## Evaluation Context -If you are developing a feature that needs to use a feature flag, or you are implementing an A/B-testing scenario, this is how you should fetch the value of your feature flags (for either server and browser side code): +The fields populated by this plugin in the evaluation context are shown in the JSON snippet below. +It reports the context split in 2 levels: `kibana` and `organization`. This should help providing a consistent behavior +for all users in a deployment/project, or for all the deployments in an organization. -First, you should declare the optional dependency on this plugin. Do not list it in your `requiredPlugins`, as this plugin is disabled by default and only enabled in Cloud deployments. Adding it to your `requiredPlugins` will cause Kibana to refuse to start by default. - -```json -// plugin/kibana.json +```JSON { - "id": "myPlugin", - "optionalPlugins": ["cloudExperiments"] -} -``` - -Please, be aware that your plugin will run even when the `cloudExperiment` plugin is disabled. Make sure to declare it as an optional dependency in your plugin's TypeScript contract to remind you that it might not always be available. - -### Fetching the value of the feature flags - -First, make sure that your feature flag is listed in [`FEATURE_FLAG_NAMES`](./common/constants.ts). -Then, you can fetch the value of your feature flag by using the API `cloudExperiments.getVariation` as follows: - -```ts -import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/(public|server)'; -import type { - CloudExperimentsPluginSetup, - CloudExperimentsPluginStart -} from '@kbn/cloud-experiments-plugin/common'; - -interface SetupDeps { - cloudExperiments?: CloudExperimentsPluginSetup; -} - -interface StartDeps { - cloudExperiments?: CloudExperimentsPluginStart; -} - -export class MyPlugin implements Plugin { - public setup(core: CoreSetup, deps: SetupDeps) { - this.doSomethingBasedOnFeatureFlag(deps.cloudExperiments); + "kind": "multi", + "kibana": { + "key": "deployment/project ID", + "offering": "traditional/serverless", + "version": "8.16.0", + "build_num": 1234, + "build_sha": "cdadaasdasdjsljhl", + "build_sha_short": "cdada", + "project_type": "Serverless project type", + "orchestrator_target": "canary/non-canary", + "has_data": true + }, + "organization": { + "key": "Cloud Organization ID", + "is_elastic_staff": false, + "in_trial": false, + "trial_end_date": "2024-01-01T01:00:00.000Z" } - - public start(core: CoreStart, deps: StartDeps) { - this.doSomethingBasedOnFeatureFlag(deps.cloudExperiments); - } - - private async doSomethingBasedOnFeatureFlag(cloudExperiments?: CloudExperimentsPluginStart) { - let myConfig = 'default config'; - if (cloudExperiments) { - myConfig = await cloudExperiments.getVariation( - 'my-plugin.my-feature-flag', // The key 'my-plugin.my-feature-flag' should exist in FEATURE_FLAG_NAMES - 'default config' - ); - } - // do something with the final value of myConfig... - } -} -``` - -Since the `getVariation` API returns a promise, when using it in a React component, you may want to use the hook `useEffect`. - -```tsx -import React, { useEffect, useState } from 'react'; -import type { - CloudExperimentsFeatureFlagNames, - CloudExperimentsPluginStart -} from '@kbn/cloud-experiments-plugin/common'; - -interface Props { - cloudExperiments?: CloudExperimentsPluginStart; -} - -const useVariation = ( - cloudExperiments: CloudExperimentsPluginStart | undefined, - featureFlagName: CloudExperimentsFeatureFlagNames, - defaultValue: Data, - setter: (value: Data) => void -) => { - useEffect(() => { - (async function loadVariation() { - const variationUrl = await cloudExperiments?.getVariation(featureFlagName, defaultValue); - if (variationUrl) { - setter(variationUrl); - } - })(); - }, [cloudExperiments, featureFlagName, defaultValue, setter]); -}; - -export const MyReactComponent: React.FC = ({ cloudExperiments }: Props) => { - const [myConfig, setMyConfig] = useState('default config'); - useVariation( - cloudExperiments, - 'my-plugin.my-feature-flag', // The key 'my-plugin.my-feature-flag' should exist in FEATURE_FLAG_NAMES - 'default config', - setMyConfig - ); - - // use myConfig in the component... } ``` - -### Reporting metrics - -Experiments require feedback to analyze which variation to the feature flag is the most successful. For this reason, we need to report some metrics defined in the success criteria of the experiment (check back with your PM if they are unclear). - -Our A/B testing provider allows some high-level analysis of the experiment based on the metrics. It also has some limitations about how it handles some type of metrics like number of objects or size of indices. For this reason, you might want to consider shipping the metrics via our usual telemetry channels (`core.analytics` for event-based metrics, or ). - -However, if our A/B testing provider's analysis tool is good enough for your use case, you can use the api `reportMetric` as follows. - -First, make sure to add the metric name in [`METRIC_NAMES`](./common/constants.ts). Then you can use it like below: - -```ts -import type { CoreStart, Plugin } from '@kbn/core/(public|server)'; -import type { - CloudExperimentsPluginSetup, - CloudExperimentsPluginStart -} from '@kbn/cloud-experiments-plugin/common'; - -interface SetupDeps { - cloudExperiments?: CloudExperimentsPluginSetup; -} - -interface StartDeps { - cloudExperiments?: CloudExperimentsPluginStart; -} - -export class MyPlugin implements Plugin { - public start(core: CoreStart, deps: StartDeps) { - // whenever we need to report any metrics: - // the user performed some action, - // or a metric hit a threshold we want to communicate about - deps.cloudExperiments?.reportMetric({ - name: 'Something happened', // The key 'Something happened' should exist in METRIC_NAMES - value: 22, // (optional) in case the metric requires a numeric metric - meta: { // Optional metadata. - hadSomething: true, - userType: 'type 1', - otherNumericField: 1, - } - }) - } -} -``` - -### Testing - -To test your code locally when developing the A/B scenarios, this plugin accepts a custom config to skip the A/B provider calls and return the values. Use the following `kibana.dev.yml` configuration as an example: - -```yml -xpack.cloud_integrations.experiments.enabled: true -xpack.cloud_integrations.experiments.flag_overrides: - "my-plugin.my-feature-flag": "my custom value" -``` - -### How is my user identified? - -The user is automatically identified during the `setup` phase. It currently uses the ESS deployment ID, meaning all users accessing the same deployment will get the same values for the `getVariation` requests unless the A/B provider is explicitly configured to randomize it. - -If you are curious of the data provided to the `identify` call, you can see that in the [`cloud` plugin](../../cloud). - ---- - -## Development - -See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.test.ts deleted file mode 100644 index 8ff277b4abe59a..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FEATURE_FLAG_NAMES, METRIC_NAMES } from './constants'; - -function removeDuplicates(obj: Record) { - return [...new Set(Object.values(obj))]; -} - -describe('constants', () => { - describe('FEATURE_FLAG_NAMES', () => { - test('the values should not include duplicates', () => { - expect(Object.values(FEATURE_FLAG_NAMES)).toStrictEqual(removeDuplicates(FEATURE_FLAG_NAMES)); - }); - }); - describe('METRIC_NAMES', () => { - test('the values should not include duplicates', () => { - expect(Object.values(METRIC_NAMES)).toStrictEqual(removeDuplicates(METRIC_NAMES)); - }); - }); -}); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts deleted file mode 100644 index 4efbca83ce2cc3..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/** - * List of feature flag names used in Kibana. - * - * Feel free to add/remove entries if needed. - * - * As a convention, the key and the value have the same string. - * - * @remarks Kept centralized in this place to serve as a repository - * to help devs understand if there is someone else already using it. - */ -export enum FEATURE_FLAG_NAMES { - /** - * Used in the Security Solutions onboarding page. - * It resolves the URL that the button "Add Integrations" will point to. - */ - 'security-solutions.add-integrations-url' = 'security-solutions.add-integrations-url', - /** - * Used in cloud chat plugin to enable/disable the chat. - * The expectation that the chat is enabled by default and the flag is used as a runtime kill switch. - */ - 'cloud-chat.enabled' = 'cloud-chat.enabled', - /** - * Used in cloud chat plugin to switch between the chat variants. - * Options are: 'header' (the chat button appears as part of the kibana header) and 'bubble' (floating chat button at the bottom of the screen). - */ - 'cloud-chat.chat-variant' = 'cloud-chat.chat-variant', - /** - * Used in observability onboarding plugin to enable/disable the experimental onboarding flow. - * Options are: `true` and `false`. - */ - 'observability_onboarding.experimental_onboarding_flow_enabled' = 'observability_onboarding.experimental_onboarding_flow_enabled', -} - -/** - * List of LaunchDarkly metric names used in Kibana. - * - * Feel free to add/remove entries if needed. - * - * As a convention, the key and the value have the same string. - * - * @remarks Kept centralized in this place to serve as a repository - * to help devs understand if there is someone else already using it. - */ -export enum METRIC_NAMES {} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/index.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/index.ts deleted file mode 100755 index 78874d5e7dda09..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export type { - CloudExperimentsMetric, - CloudExperimentsMetricNames, - CloudExperimentsPluginStart, - CloudExperimentsFeatureFlagNames, -} from './types'; diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/index.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/index.ts index 74e2655e8302f4..b62a96ea3613f8 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/index.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/index.ts @@ -6,3 +6,4 @@ */ export { MetadataService } from './metadata_service'; +export { initializeMetadata } from './initialize_metadata'; diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/initialize_metadata.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/initialize_metadata.ts new file mode 100644 index 00000000000000..ff9d8b9715ce1e --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/initialize_metadata.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { concatMap } from 'rxjs'; +import type { CloudSetup as CloudSetupBrowser } from '@kbn/cloud-plugin/public'; +import type { CloudSetup as CloudSetupServer } from '@kbn/cloud-plugin/server'; +import type { PluginInitializerContext as PluginInitializerContextBrowser } from '@kbn/core-plugins-browser'; +import type { PluginInitializerContext as PluginInitializerContextServer } from '@kbn/core-plugins-server'; +import type { FeatureFlagsSetup as FeatureFlagsSetupBrowser } from '@kbn/core-feature-flags-browser'; +import type { FeatureFlagsSetup as FeatureFlagsSetupServer } from '@kbn/core-feature-flags-server'; +import type { Logger } from '@kbn/logging'; +import type { MetadataService } from './metadata_service'; + +/** + * @private + */ +export function initializeMetadata({ + metadataService, + initializerContext, + featureFlags, + cloud, + logger, +}: { + metadataService: MetadataService; + initializerContext: PluginInitializerContextBrowser | PluginInitializerContextServer; + featureFlags: FeatureFlagsSetupBrowser | FeatureFlagsSetupServer; + cloud: CloudSetupBrowser | CloudSetupServer; + logger: Logger; +}) { + const offering = initializerContext.env.packageInfo.buildFlavor; + + metadataService.setup({ + instanceKey: cloud.serverless?.projectId || cloud.deploymentId, + offering, + version: initializerContext.env.packageInfo.version, + build_num: initializerContext.env.packageInfo.buildNum, + build_sha: initializerContext.env.packageInfo.buildSha, + build_sha_short: initializerContext.env.packageInfo.buildShaShort, + project_type: cloud.serverless.projectType, + orchestrator_target: cloud.serverless.orchestratorTarget, + organizationKey: cloud.organizationId, + trial_end_date: cloud.trialEndDate, + is_elastic_staff: cloud.isElasticStaffOwned, + }); + + // Update the client's contexts when we get any updates in the metadata. + metadataService.userMetadata$ + .pipe( + // Using concatMap to ensure we call the promised update in an orderly manner to avoid concurrency issues + concatMap(async (userMetadata) => { + try { + await featureFlags.appendContext(userMetadata); + } catch (err) { + logger.warn(`Failed to set the feature flags context ${err}`); + } + }) + ) + .subscribe(); // This subscription will stop when the metadataService stops because it completes the Observable +} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.test.ts index 0c0f5f5127f0f7..92798581c8507e 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.test.ts @@ -8,8 +8,8 @@ import moment from 'moment'; import { fakeSchedulers } from 'rxjs-marbles/jest'; import { firstValueFrom } from 'rxjs'; -import { MetadataService } from './metadata_service'; import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; +import { type FlatMetadata, MetadataService } from './metadata_service'; jest.mock('rxjs', () => { const RxJs = jest.requireActual('rxjs'); @@ -22,7 +22,6 @@ jest.mock('rxjs', () => { describe('MetadataService', () => { jest.useFakeTimers({ legacyFakeTimers: true }); - let metadataService: MetadataService; let logger: MockedLogger; @@ -39,43 +38,73 @@ describe('MetadataService', () => { jest.clearAllMocks(); }); + const initialMetadata: FlatMetadata = { + instanceKey: 'project-id', + offering: 'serverless', + version: '1.2.3', + build_num: 123, + build_sha: 'abcdefghijklmnopqrstux', + build_sha_short: 'abcde', + project_type: 'project-type', + organizationKey: 'organization-id', + is_elastic_staff: true, + }; + + const multiContextFormat = { + kind: 'multi', + kibana: { + key: 'project-id', + offering: 'serverless', + version: '1.2.3', + build_num: 123, + build_sha: 'abcdefghijklmnopqrstux', + build_sha_short: 'abcde', + project_type: 'project-type', + }, + organization: { + key: 'organization-id', + is_elastic_staff: true, + }, + }; + describe('setup', () => { test('emits the initial metadata', async () => { - const initialMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' }; metadataService.setup(initialMetadata); await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata + multiContextFormat ); }); test( 'emits inTrial when trialEndDate is provided', fakeSchedulers(async (advance) => { - const initialMetadata = { - userId: 'fake-user-id', - kibanaVersion: 'version', - trialEndDate: new Date(0).toISOString(), - }; - metadataService.setup(initialMetadata); + metadataService.setup({ ...initialMetadata, trial_end_date: new Date(0) }); // Still equals initialMetadata - await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata - ); + await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({ + ...multiContextFormat, + organization: { + ...multiContextFormat.organization, + trial_end_date: new Date(0), + }, + }); // After scheduler kicks in... advance(1); // The timer kicks in first on 0 (but let's give us 1ms so the trial is expired) await new Promise((resolve) => process.nextTick(resolve)); // The timer triggers a promise, so we need to skip to the next tick await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({ - ...initialMetadata, - inTrial: false, + ...multiContextFormat, + organization: { + ...multiContextFormat.organization, + trial_end_date: new Date(0), + in_trial: false, + }, }); }) ); }); describe('start', () => { - const initialMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' }; beforeEach(() => { metadataService.setup(initialMetadata); }); @@ -83,19 +112,22 @@ describe('MetadataService', () => { test( 'emits hasData after resolving the `hasUserDataView`', fakeSchedulers(async (advance) => { - metadataService.start({ hasDataFetcher: async () => ({ hasData: true }) }); + metadataService.start({ hasDataFetcher: async () => ({ has_data: true }) }); // Still equals initialMetadata await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata + multiContextFormat ); // After scheduler kicks in... advance(1); // The timer kicks in first on 0 (but let's give us 1ms so the trial is expired) await new Promise((resolve) => process.nextTick(resolve)); // The timer triggers a promise, so we need to skip to the next tick await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({ - ...initialMetadata, - hasData: true, + ...multiContextFormat, + kibana: { + ...multiContextFormat.kibana, + has_data: true, + }, }); }) ); @@ -107,7 +139,7 @@ describe('MetadataService', () => { metadataService.start({ hasDataFetcher: async () => { if (count++ > 0) { - return { hasData: true }; + return { has_data: true }; } else { throw new Error('Something went wrong'); } @@ -116,7 +148,7 @@ describe('MetadataService', () => { // Still equals initialMetadata await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata + multiContextFormat ); // After scheduler kicks in... @@ -125,7 +157,7 @@ describe('MetadataService', () => { // Still equals initialMetadata await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual( - initialMetadata + multiContextFormat ); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn.mock.calls[0][0]).toMatchInlineSnapshot( @@ -136,8 +168,11 @@ describe('MetadataService', () => { advance(1_001); await new Promise((resolve) => process.nextTick(resolve)); // The timer triggers a promise, so we need to skip to the next tick await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({ - ...initialMetadata, - hasData: true, + ...multiContextFormat, + kibana: { + ...multiContextFormat.kibana, + has_data: true, + }, }); }) ); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.ts index ddb2bc86d7dcab..06c28a16c8032f 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.ts @@ -17,23 +17,87 @@ import { takeUntil, takeWhile, timer, + map, } from 'rxjs'; import { type Duration } from 'moment'; import type { Logger } from '@kbn/logging'; +import type { BuildFlavor } from '@kbn/config'; +import type { EvaluationContext } from '@kbn/core-feature-flags-browser'; +import { removeUndefined } from './remove_undefined'; export interface MetadataServiceStartContract { - hasDataFetcher: () => Promise<{ hasData: boolean }>; + hasDataFetcher: () => Promise<{ has_data: boolean }>; } -export interface UserMetadata extends Record { +export interface FlatMetadata { // Static values - userId: string; - kibanaVersion: string; - trialEndDate?: string; - isElasticStaff?: boolean; + /** + * The deployment/project ID + * @group Kibana Static Values + */ + instanceKey?: string; + /** + * The offering (serverless/traditional) + * @group Kibana Static Values + */ + offering: BuildFlavor; + /** + * The Kibana version + * @group Kibana Static Values + */ + version: string; + /** + * The Kibana build number + * @group Kibana Static Values + */ + build_num: number; + /** + * The Kibana build sha + * @group Kibana Static Values + */ + build_sha: string; + /** + * The Kibana build sha (short format) + * @group Kibana Static Values + */ + build_sha_short: string; + /** + * The Serverless project type (only available on serverless) + * @group Kibana Static Values + */ + project_type?: string; + /** + * Whether this is a canary or non-canary project/deployment + * @group Kibana Static Values + */ + orchestrator_target?: string; + /** + * The Elastic Cloud Organization's ID + * @group Organization Static Values + */ + organizationKey?: string; + /** + * The Elastic Cloud Organization's trial end date. + * @group Organization Static Values + */ + trial_end_date?: Date; + /** + * Is the Elastic Cloud Organization owned by an Elastician. + * @group Organization Static Values + */ + is_elastic_staff?: boolean; + // Dynamic/calculated values - inTrial?: boolean; - hasData?: boolean; + /** + * Is the Elastic Cloud Organization in trial. + * @group Organization Dynamic Values + */ + in_trial?: boolean; + /** + * Does the deployment/project have any data ingested? + * @group Kibana Dynamic Values + */ + has_data?: boolean; } export interface MetadataServiceConfig { @@ -41,31 +105,58 @@ export interface MetadataServiceConfig { } export class MetadataService { - private readonly _userMetadata$ = new BehaviorSubject(undefined); + private readonly _userMetadata$ = new BehaviorSubject(undefined); private readonly stop$ = new Subject(); constructor(private readonly config: MetadataServiceConfig, private readonly logger: Logger) {} - public setup(initialUserMetadata: UserMetadata) { + public setup(initialUserMetadata: FlatMetadata) { this._userMetadata$.next(initialUserMetadata); // Calculate `inTrial` based on the `trialEndDate`. // Elastic Cloud allows customers to end their trials earlier or even extend it in some cases, but this is a good compromise for now. - const trialEndDate = initialUserMetadata.trialEndDate; + const trialEndDate = initialUserMetadata.trial_end_date; if (trialEndDate) { this.scheduleUntil( - () => ({ inTrial: Date.now() <= new Date(trialEndDate).getTime() }), + () => ({ in_trial: Date.now() <= new Date(trialEndDate).getTime() }), // Stop recalculating inTrial when the user is no-longer in trial - (metadata) => metadata.inTrial === false + (metadata) => metadata.in_trial === false ); } } - public get userMetadata$(): Observable { + public get userMetadata$(): Observable { return this._userMetadata$.pipe( filter(Boolean), // Ensure we don't return undefined debounceTime(100), // Swallows multiple emissions that may occur during bootstrap - distinct((meta) => [meta.inTrial, meta.hasData].join('-')), // Checks if any of the dynamic fields have changed + distinct((meta) => [meta.in_trial, meta.has_data].join('-')), // Checks if any of the dynamic fields have changed + map((metadata) => { + const context: EvaluationContext = { + kind: 'multi', + ...(metadata.instanceKey && { + kibana: removeUndefined({ + key: metadata.instanceKey, + offering: metadata.offering, + version: metadata.version, + build_num: metadata.build_num, + build_sha: metadata.build_sha, + build_sha_short: metadata.build_sha_short, + project_type: metadata.project_type, + orchestrator_target: metadata.orchestrator_target, + has_data: metadata.has_data, + }), + }), + ...(metadata.organizationKey && { + organization: removeUndefined({ + key: metadata.organizationKey, + is_elastic_staff: metadata.is_elastic_staff, + in_trial: metadata.in_trial, + trial_end_date: metadata.trial_end_date, + }), + }), + }; + return context; + }), shareReplay(1) ); } @@ -77,7 +168,7 @@ export class MetadataService { this.scheduleUntil( async () => hasDataFetcher(), // Stop checking the moment the user has any data - (metadata) => metadata.hasData === true + (metadata) => metadata.has_data === true ); } @@ -87,14 +178,14 @@ export class MetadataService { } /** - * Schedules a timer that calls `fn` to update the {@link UserMetadata} until `untilFn` returns true. + * Schedules a timer that calls `fn` to update the {@link FlatMetadata} until `untilFn` returns true. * @param fn Method to calculate the dynamic metadata. * @param untilFn Method that returns true when the scheduler should stop calling fn (potentially because the dynamic value is not expected to change anymore). * @private */ private scheduleUntil( - fn: () => Partial | Promise>, - untilFn: (value: UserMetadata) => boolean + fn: () => Partial | Promise>, + untilFn: (value: FlatMetadata) => boolean ) { timer(0, this.config.metadata_refresh_interval.asMilliseconds()) .pipe( diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/remove_undefined.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/remove_undefined.ts new file mode 100644 index 00000000000000..437335a0f4096a --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/remove_undefined.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type NonUndefinedProps = { [P in keyof T]-?: NonNullable }; + +export function removeUndefined>( + record: T +): NonUndefinedProps { + return Object.fromEntries( + Object.entries(record).filter(([, val]) => typeof val !== 'undefined') + ) as NonUndefinedProps; +} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/mocks.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/mocks.ts deleted file mode 100644 index fd18c3ee2420d5..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/mocks.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { CloudExperimentsPluginStart } from './types'; - -function createStartMock(): jest.Mocked { - return { - getVariation: jest.fn(), - reportMetric: jest.fn(), - }; -} - -export const cloudExperimentsMock = { - createStartMock, -}; diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/common/types.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/types.ts deleted file mode 100755 index e7b87eee12fc97..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/types.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FEATURE_FLAG_NAMES, METRIC_NAMES } from './constants'; - -/** - * The names of the feature flags declared in Kibana. - * Valid keys are defined in {@link FEATURE_FLAG_NAMES}. When using a new feature flag, add the name to the list. - * - * @public - */ -export type CloudExperimentsFeatureFlagNames = keyof typeof FEATURE_FLAG_NAMES; - -/** - * The contract of the start lifecycle method - * - * @public - * @deprecated in favor of the upcoming Core Feature Flags Service. - */ -export interface CloudExperimentsPluginStart { - /** - * Fetch the configuration assigned to variation `configKey`. If nothing is found, fallback to `defaultValue`. - * @param featureFlagName The name of the key to find the config variation. {@link CloudExperimentsFeatureFlagNames}. - * @param defaultValue The fallback value in case no variation is found. - * - * @public - * @deprecated in favor of the upcoming Core Feature Flags Service. - */ - getVariation: ( - featureFlagName: CloudExperimentsFeatureFlagNames, - defaultValue: Data - ) => Promise; - /** - * Report metrics back to the A/B testing service to measure the conversion rate for each variation in the experiment. - * @param metric {@link CloudExperimentsMetric} - * - * @public - * @deprecated in favor of the upcoming Core Feature Flags Service. - */ - reportMetric: (metric: CloudExperimentsMetric) => void; -} - -/** - * The names of the metrics declared in Kibana. - * Valid keys are defined in {@link METRIC_NAMES}. When reporting a new metric, add the name to the list. - * - * @public - */ -export type CloudExperimentsMetricNames = keyof typeof METRIC_NAMES; - -/** - * Definition of the metric to report back to the A/B testing service to measure the conversions. - * - * @public - */ -export interface CloudExperimentsMetric { - /** - * The name of the metric {@link CloudExperimentsMetricNames} - */ - name: CloudExperimentsMetricNames; - /** - * Any optional data to enrich the context of the metric. Or if the conversion is based on a non-numeric value. - */ - meta?: Data; - /** - * The numeric value of the metric. Bear in mind that they are averaged by the underlying solution. - * Typical values to report here are time-to-action, number of panels in a loaded dashboard, and page load time. - */ - value?: number; -} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.jsonc index 743bf70001dd63..3c6b9f8279f018 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.jsonc +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.jsonc @@ -14,9 +14,7 @@ ], "requiredPlugins": [ "cloud", - "dataViews" - ], - "optionalPlugins": [ + "dataViews", "usageCollection" ] } diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/index.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/index.ts deleted file mode 100644 index ac961286b70439..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { - LaunchDarklyClient, - type LaunchDarklyUserMetadata, - type LaunchDarklyClientConfig, -} from './launch_darkly_client'; diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.mock.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.mock.ts deleted file mode 100644 index b6a43a7d0715b7..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.mock.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { LDClient } from 'launchdarkly-js-client-sdk'; - -export function createLaunchDarklyClientMock(): jest.Mocked { - return { - identify: jest.fn(), - waitForInitialization: jest.fn(), - variation: jest.fn(), - track: jest.fn(), - flush: jest.fn(), - } as unknown as jest.Mocked; // Using casting because we only use these APIs. No need to declare everything. -} - -export const ldClientMock = createLaunchDarklyClientMock(); - -export const launchDarklyLibraryMock = { - initialize: jest.fn(), - basicLogger: jest.fn(), -}; - -jest.doMock('launchdarkly-js-client-sdk', () => launchDarklyLibraryMock); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.ts deleted file mode 100644 index 998733707f0c07..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { coreMock } from '@kbn/core/public/mocks'; -import { ldClientMock, launchDarklyLibraryMock } from './launch_darkly_client.test.mock'; -import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client'; - -describe('LaunchDarklyClient - browser', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const config: LaunchDarklyClientConfig = { - client_id: 'fake-client-id', - client_log_level: 'debug', - }; - - describe('Public APIs', () => { - let client: LaunchDarklyClient; - const testUserMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' }; - const loggerWarnSpy = jest.fn(); - beforeEach(() => { - const initializerContext = coreMock.createPluginInitializerContext(); - const logger = initializerContext.logger.get(); - logger.warn = loggerWarnSpy; - client = new LaunchDarklyClient(config, 'version', logger); - }); - - describe('updateUserMetadata', () => { - test("calls the client's initialize method with all the possible values", async () => { - launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock); - - const topFields = { - name: 'First Last', - firstName: 'First', - lastName: 'Last', - email: 'first.last@boring.co', - avatar: 'fake-blue-avatar', - ip: 'my-weird-ip', - country: 'distributed', - // intentionally adding this to make sure the code is overriding appropriately - kind: 'other kind', - key: 'other user', - }; - - const extraFields = { - other_field: 'my other custom field', - kibanaVersion: 'version', - }; - - await client.updateUserMetadata({ userId: 'fake-user-id', ...topFields, ...extraFields }); - - expect(launchDarklyLibraryMock.initialize).toHaveBeenCalledWith( - 'fake-client-id', - { - ...topFields, - ...extraFields, - kind: 'user', - key: 'fake-user-id', - }, - { - application: { id: 'kibana-browser', version: 'version' }, - logger: undefined, - } - ); - }); - - test('sets a minimum amount of info', async () => { - await client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' }); - - expect(launchDarklyLibraryMock.initialize).toHaveBeenCalledWith( - 'fake-client-id', - { - kind: 'user', - key: 'fake-user-id', - kibanaVersion: 'version', - }, - { - application: { id: 'kibana-browser', version: 'version' }, - logger: undefined, - } - ); - }); - - test('calls identify if an update comes after initializing the client', async () => { - launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock); - await client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' }); - - expect(launchDarklyLibraryMock.initialize).toHaveBeenCalledWith( - 'fake-client-id', - { - kind: 'user', - key: 'fake-user-id', - kibanaVersion: 'version', - }, - { - application: { id: 'kibana-browser', version: 'version' }, - logger: undefined, - } - ); - expect(ldClientMock.identify).not.toHaveBeenCalled(); - - // Update user metadata a 2nd time - launchDarklyLibraryMock.initialize.mockReset(); - await client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' }); - expect(ldClientMock.identify).toHaveBeenCalledWith({ - kind: 'user', - key: 'fake-user-id', - kibanaVersion: 'version', - }); - expect(launchDarklyLibraryMock.initialize).not.toHaveBeenCalled(); - }); - }); - - describe('getVariation', () => { - test('waits for the user to been defined and does NOT return default value', async () => { - ldClientMock.variation.mockResolvedValue(1234); // Expected is 1234 - launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock); - const promise = client.getVariation('my-feature-flag', 123); // Default value is 123 - - await client.updateUserMetadata(testUserMetadata); - await expect(promise).resolves.toStrictEqual(1234); - expect(ldClientMock.variation).toHaveBeenCalledTimes(1); - }); - - test('return default value if canceled', async () => { - ldClientMock.variation.mockResolvedValue(1234); - launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock); - const promise = client.getVariation('my-feature-flag', 123); // Default value is 123 - - client.cancel(); - - await client.updateUserMetadata(testUserMetadata); - await expect(promise).resolves.toStrictEqual(123); // default value - expect(ldClientMock.variation).toHaveBeenCalledTimes(0); - expect(launchDarklyLibraryMock.initialize).not.toHaveBeenCalled(); - }); - - test('calls the LaunchDarkly client when the user has been defined', async () => { - ldClientMock.variation.mockResolvedValue(1234); - launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock); - await client.updateUserMetadata(testUserMetadata); - await expect(client.getVariation('my-feature-flag', 123)).resolves.toStrictEqual(1234); - expect(ldClientMock.variation).toHaveBeenCalledTimes(1); - expect(ldClientMock.variation).toHaveBeenCalledWith('my-feature-flag', 123); - }); - }); - - describe('reportMetric', () => { - test('does not call track if the user has not been defined', () => { - client.reportMetric('my-feature-flag', {}, 123); - expect(ldClientMock.track).toHaveBeenCalledTimes(0); - }); - - test('calls the LaunchDarkly client when the user has been defined', async () => { - launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock); - await client.updateUserMetadata(testUserMetadata); - client.reportMetric('my-feature-flag', {}, 123); - await new Promise((resolve) => process.nextTick(resolve)); // wait for the client to be available - expect(ldClientMock.track).toHaveBeenCalledTimes(1); - expect(ldClientMock.track).toHaveBeenCalledWith('my-feature-flag', {}, 123); - }); - }); - - describe('stop', () => { - test('flushes the events', async () => { - launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock); - await client.updateUserMetadata(testUserMetadata); - - ldClientMock.flush.mockResolvedValue(); - expect(() => client.stop()).not.toThrow(); - await new Promise((resolve) => process.nextTick(resolve)); // wait for the client to be available - expect(ldClientMock.flush).toHaveBeenCalledTimes(1); - await new Promise((resolve) => process.nextTick(resolve)); // wait for the flush resolution - }); - - test('handles errors when flushing events', async () => { - launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock); - await client.updateUserMetadata(testUserMetadata); - - const err = new Error('Something went terribly wrong'); - ldClientMock.flush.mockRejectedValue(err); - expect(() => client.stop()).not.toThrow(); - await new Promise((resolve) => process.nextTick(resolve)); - expect(ldClientMock.flush).toHaveBeenCalledTimes(1); - await new Promise((resolve) => process.nextTick(resolve)); // wait for the flush resolution - expect(loggerWarnSpy).toHaveBeenCalledWith(err); - }); - }); - }); -}); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts deleted file mode 100644 index bc2064ec6bcf03..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - type LDClient, - type LDSingleKindContext, - type LDLogLevel, -} from 'launchdarkly-js-client-sdk'; -import { BehaviorSubject, filter, firstValueFrom, switchMap } from 'rxjs'; -import type { Logger } from '@kbn/logging'; - -export interface LaunchDarklyClientConfig { - client_id: string; - client_log_level: LDLogLevel; -} - -export interface LaunchDarklyUserMetadata - extends Record { - userId: string; -} - -export class LaunchDarklyClient { - private initialized = false; - private canceled = false; - private launchDarklyClientSub$ = new BehaviorSubject(null); - private loadingClient$ = new BehaviorSubject(true); - private launchDarklyClient$ = this.loadingClient$.pipe( - // To avoid a racing condition when trying to get a variation before the client is ready - // we use the `switchMap` operator to ensure we only return the client when it has been initialized. - filter((loading) => !loading), - switchMap(() => this.launchDarklyClientSub$) - ); - - constructor( - private readonly ldConfig: LaunchDarklyClientConfig, - private readonly kibanaVersion: string, - private readonly logger: Logger - ) {} - - public async updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) { - if (this.canceled) return; - - const { userId, ...userMetadataWithoutUserId } = userMetadata; - const launchDarklyUser: LDSingleKindContext = { - ...userMetadataWithoutUserId, - kind: 'user', - key: userId, - }; - - let launchDarklyClient: LDClient | null = null; - if (this.initialized) { - launchDarklyClient = await this.getClient(); - } - - if (launchDarklyClient) { - await launchDarklyClient.identify(launchDarklyUser); - } else { - this.initialized = true; - const { initialize, basicLogger } = await import('launchdarkly-js-client-sdk'); - launchDarklyClient = initialize(this.ldConfig.client_id, launchDarklyUser, { - application: { id: 'kibana-browser', version: this.kibanaVersion }, - logger: basicLogger({ level: this.ldConfig.client_log_level }), - }); - this.launchDarklyClientSub$.next(launchDarklyClient); - this.loadingClient$.next(false); - } - } - - public async getVariation(configKey: string, defaultValue: Data): Promise { - const launchDarklyClient = await this.getClient(); - if (!launchDarklyClient) return defaultValue; // Skip any action if no LD User is defined - await launchDarklyClient.waitForInitialization(); - return await launchDarklyClient.variation(configKey, defaultValue); - } - - public reportMetric(metricName: string, meta?: unknown, value?: number): void { - this.getClient().then((launchDarklyClient) => { - if (!launchDarklyClient) return; // Skip any action if no LD User is defined - launchDarklyClient.track(metricName, meta, value); - }); - } - - public stop() { - this.getClient().then((launchDarklyClient) => { - launchDarklyClient?.flush().catch((err) => { - this.logger.warn(err); - }); - }); - } - - public cancel() { - this.initialized = true; - this.canceled = true; - this.loadingClient$.next(false); - } - - private getClient(): Promise { - return firstValueFrom(this.launchDarklyClient$, { defaultValue: null }); - } -} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.ts index 7c945afcf53f35..59a20b198e70bd 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.test.ts @@ -9,20 +9,8 @@ import { duration } from 'moment'; import { coreMock } from '@kbn/core/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; -import { CloudExperimentsPluginStart } from '../common'; -import { FEATURE_FLAG_NAMES } from '../common/constants'; import { CloudExperimentsPlugin } from './plugin'; -import { LaunchDarklyClient } from './launch_darkly_client'; import { MetadataService } from '../common/metadata_service'; -jest.mock('./launch_darkly_client'); - -function getLaunchDarklyClientInstanceMock() { - const launchDarklyClientInstanceMock = ( - LaunchDarklyClient as jest.MockedClass - ).mock.instances[0] as jest.Mocked; - - return launchDarklyClientInstanceMock; -} describe('Cloud Experiments public plugin', () => { jest.spyOn(console, 'debug').mockImplementation(); // silence console.debug logs @@ -34,59 +22,40 @@ describe('Cloud Experiments public plugin', () => { describe('constructor', () => { test('successfully creates a new plugin if provided an empty configuration', () => { const initializerContext = coreMock.createPluginInitializerContext(); - // @ts-expect-error it's defined as readonly but the mock is not. - initializerContext.env.mode.dev = true; // ensure it's true + initializerContext.env.mode = { + name: 'development', + dev: true, // ensure it's true + prod: false, + }; const plugin = new CloudExperimentsPlugin(initializerContext); expect(plugin).toHaveProperty('setup'); expect(plugin).toHaveProperty('start'); expect(plugin).toHaveProperty('stop'); - expect(plugin).toHaveProperty('flagOverrides', undefined); - expect(plugin).toHaveProperty('launchDarklyClient', undefined); expect(plugin).toHaveProperty('metadataService', expect.any(MetadataService)); }); test('fails if launch_darkly is not provided in the config and it is a non-dev environment', () => { const initializerContext = coreMock.createPluginInitializerContext(); - // @ts-expect-error it's defined as readonly but the mock is not. - initializerContext.env.mode.dev = false; + initializerContext.env.mode = { + name: 'production', + dev: false, + prod: true, // ensure it's true + }; expect(() => new CloudExperimentsPlugin(initializerContext)).toThrowError( 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); }); - - test('it initializes the flagOverrides property', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { my_flag: '1234' }, - }); - // @ts-expect-error it's defined as readonly but the mock is not. - initializerContext.env.mode.dev = true; // ensure it's true - const plugin = new CloudExperimentsPlugin(initializerContext); - expect(plugin).toHaveProperty('flagOverrides', { my_flag: '1234' }); - }); - - test('it initializes the LaunchDarkly client', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - launch_darkly: { client_id: 'sdk-1234' }, - }); - const plugin = new CloudExperimentsPlugin(initializerContext); - expect(LaunchDarklyClient).toHaveBeenCalledTimes(1); - expect(plugin).toHaveProperty('launchDarklyClient', expect.any(LaunchDarklyClient)); - }); }); describe('setup', () => { let plugin: CloudExperimentsPlugin; - let metadataServiceSetupSpy: jest.SpyInstance; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext({ launch_darkly: { client_id: '1234' }, - flag_overrides: { my_flag: '1234' }, metadata_refresh_interval: duration(1, 'h'), }); plugin = new CloudExperimentsPlugin(initializerContext); - // eslint-disable-next-line dot-notation - metadataServiceSetupSpy = jest.spyOn(plugin['metadataService'], 'setup'); }); afterEach(() => { @@ -100,60 +69,16 @@ describe('Cloud Experiments public plugin', () => { }) ).toBeUndefined(); }); - - describe('identifyUser', () => { - test('it skips creating the client if no client id provided in the config', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { my_flag: '1234' }, - metadata_refresh_interval: duration(1, 'h'), - }); - const customPlugin = new CloudExperimentsPlugin(initializerContext); - customPlugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - expect(customPlugin).toHaveProperty('launchDarklyClient', undefined); - }); - - test('it skips identifying the user if cloud is not enabled and cancels loading the LDclient', () => { - const ldClientCancelSpy = jest.spyOn(LaunchDarklyClient.prototype, 'cancel'); - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: false }, - }); - - expect(metadataServiceSetupSpy).not.toHaveBeenCalled(); - expect(ldClientCancelSpy).toHaveBeenCalled(); // Cancel loading the client - }); - - test('it initializes the LaunchDarkly client', async () => { - const ldClientCancelSpy = jest.spyOn(LaunchDarklyClient.prototype, 'cancel'); - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - - expect(metadataServiceSetupSpy).toHaveBeenCalledWith({ - isElasticStaff: true, - kibanaVersion: 'version', - trialEndDate: '2020-10-01T14:13:12.000Z', - userId: 'mock-deployment-id', - }); - expect(ldClientCancelSpy).not.toHaveBeenCalled(); - }); - }); }); describe('start', () => { let plugin: CloudExperimentsPlugin; - let launchDarklyInstanceMock: jest.Mocked; - - const firstKnownFlag = Object.keys(FEATURE_FLAG_NAMES)[0] as keyof typeof FEATURE_FLAG_NAMES; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext({ launch_darkly: { client_id: '1234' }, - flag_overrides: { [firstKnownFlag]: '1234' }, }); plugin = new CloudExperimentsPlugin(initializerContext); - launchDarklyInstanceMock = getLaunchDarklyClientInstanceMock(); }); afterEach(() => { @@ -163,168 +88,35 @@ describe('Cloud Experiments public plugin', () => { test('returns the contract', () => { plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup() }); const startContract = plugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), dataViews: dataViewPluginMocks.createStartContract(), }); - expect(startContract).toStrictEqual( - expect.objectContaining({ - getVariation: expect.any(Function), - reportMetric: expect.any(Function), - }) - ); + expect(startContract).toBeUndefined(); }); - test('triggers a userMetadataUpdate for `hasData`', async () => { - plugin.setup(coreMock.createSetup(), { + test('updates the context with `has_data`', async () => { + const coreSetup = coreMock.createSetup(); + plugin.setup(coreSetup, { cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, }); const dataViews = dataViewPluginMocks.createStartContract(); - plugin.start(coreMock.createStart(), { cloud: cloudMock.createStart(), dataViews }); + plugin.start(coreMock.createStart(), { dataViews }); // After scheduler kicks in... await new Promise((resolve) => setTimeout(resolve, 200)); - // Using a timeout of 0ms to let the `timer` kick in. - // For some reason, fakeSchedulers is not working on browser-side tests :shrug: - expect(launchDarklyInstanceMock.updateUserMetadata).toHaveBeenCalledWith( + expect(coreSetup.featureFlags.appendContext).toHaveBeenCalledWith( expect.objectContaining({ - hasData: true, + kind: 'multi', + kibana: expect.objectContaining({ + has_data: true, + }), }) ); }); - - describe('getVariation', () => { - let startContract: CloudExperimentsPluginStart; - describe('with the client created', () => { - beforeEach(() => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - startContract = plugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), - dataViews: dataViewPluginMocks.createStartContract(), - }); - }); - - test('uses the flag overrides to respond early', async () => { - await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( - '1234' - ); - }); - - test('calls the client', async () => { - launchDarklyInstanceMock.getVariation.mockResolvedValue('12345'); - await expect( - startContract.getVariation( - // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES - 'some-random-flag', - 123 - ) - ).resolves.toStrictEqual('12345'); - expect(launchDarklyInstanceMock.getVariation).toHaveBeenCalledWith( - undefined, // it couldn't find it in FEATURE_FLAG_NAMES - 123 - ); - }); - }); - - describe('with the client not created', () => { - beforeEach(() => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { [firstKnownFlag]: '1234' }, - metadata_refresh_interval: duration(1, 'h'), - }); - const customPlugin = new CloudExperimentsPlugin(initializerContext); - customPlugin.setup(coreMock.createSetup(), { - cloud: cloudMock.createSetup(), - }); - expect(customPlugin).toHaveProperty('launchDarklyClient', undefined); - startContract = customPlugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), - dataViews: dataViewPluginMocks.createStartContract(), - }); - }); - - test('uses the flag overrides to respond early', async () => { - await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( - '1234' - ); - }); - - test('returns the default value without calling the client', async () => { - await expect( - startContract.getVariation( - // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES - 'some-random-flag', - 123 - ) - ).resolves.toStrictEqual(123); - expect(launchDarklyInstanceMock.getVariation).not.toHaveBeenCalled(); - }); - }); - }); - - describe('reportMetric', () => { - let startContract: CloudExperimentsPluginStart; - describe('with the client created', () => { - beforeEach(() => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - startContract = plugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), - dataViews: dataViewPluginMocks.createStartContract(), - }); - }); - - test('calls the track API', () => { - startContract.reportMetric({ - // @ts-expect-error We only allow existing flags in METRIC_NAMES - name: 'my-flag', - meta: {}, - value: 1, - }); - expect(launchDarklyInstanceMock.reportMetric).toHaveBeenCalledWith( - undefined, // it couldn't find it in METRIC_NAMES - {}, - 1 - ); - }); - }); - - describe('with the client not created', () => { - beforeEach(() => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { [firstKnownFlag]: '1234' }, - metadata_refresh_interval: duration(1, 'h'), - }); - const customPlugin = new CloudExperimentsPlugin(initializerContext); - customPlugin.setup(coreMock.createSetup(), { - cloud: cloudMock.createSetup(), - }); - expect(customPlugin).toHaveProperty('launchDarklyClient', undefined); - startContract = customPlugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), - dataViews: dataViewPluginMocks.createStartContract(), - }); - }); - - test('calls the track API', () => { - startContract.reportMetric({ - // @ts-expect-error We only allow existing flags in METRIC_NAMES - name: 'my-flag', - meta: {}, - value: 1, - }); - expect(launchDarklyInstanceMock.reportMetric).not.toHaveBeenCalled(); - }); - }); - }); }); describe('stop', () => { let plugin: CloudExperimentsPlugin; - let launchDarklyInstanceMock: jest.Mocked; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext({ @@ -333,19 +125,19 @@ describe('Cloud Experiments public plugin', () => { metadata_refresh_interval: duration(1, 'h'), }); plugin = new CloudExperimentsPlugin(initializerContext); - launchDarklyInstanceMock = getLaunchDarklyClientInstanceMock(); plugin.setup(coreMock.createSetup(), { cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, }); plugin.start(coreMock.createStart(), { - cloud: cloudMock.createStart(), dataViews: dataViewPluginMocks.createStartContract(), }); }); test('flushes the events on stop', () => { + // eslint-disable-next-line dot-notation + const metadataServiceStopSpy = jest.spyOn(plugin['metadataService'], 'stop'); expect(() => plugin.stop()).not.toThrow(); - expect(launchDarklyInstanceMock.stop).toHaveBeenCalledTimes(1); + expect(metadataServiceStopSpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts index a201c98df1ea36..ee95019e6fa17d 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts @@ -5,53 +5,42 @@ * 2.0. */ -import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; -import { get, has } from 'lodash'; import { duration } from 'moment'; -import { concatMap } from 'rxjs'; -import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; -import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { Logger } from '@kbn/logging'; - -import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client'; -import type { - CloudExperimentsFeatureFlagNames, - CloudExperimentsMetric, - CloudExperimentsPluginStart, -} from '../common'; -import { MetadataService } from '../common/metadata_service'; -import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants'; +import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import type { CloudSetup } from '@kbn/cloud-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { LaunchDarklyClientProvider } from '@openfeature/launchdarkly-client-provider'; +import { type LDLogLevel, basicLogger } from 'launchdarkly-js-client-sdk'; +import { initializeMetadata, MetadataService } from '../common/metadata_service'; interface CloudExperimentsPluginSetupDeps { cloud: CloudSetup; } interface CloudExperimentsPluginStartDeps { - cloud: CloudStart; dataViews: DataViewsPublicPluginStart; } +interface LaunchDarklyClientConfig { + client_id: string; + client_log_level: LDLogLevel; +} + /** * Browser-side implementation of the Cloud Experiments plugin */ export class CloudExperimentsPlugin - implements Plugin + implements Plugin { private readonly logger: Logger; private readonly metadataService: MetadataService; - private readonly launchDarklyClient?: LaunchDarklyClient; - private readonly kibanaVersion: string; - private readonly flagOverrides?: Record; - private readonly isDev: boolean; /** Constructor of the plugin **/ - constructor(initializerContext: PluginInitializerContext) { + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); - this.isDev = initializerContext.env.mode.dev; - this.kibanaVersion = initializerContext.env.packageInfo.version; const config = initializerContext.config.get<{ launch_darkly?: LaunchDarklyClientConfig; - flag_overrides?: Record; metadata_refresh_interval: string; }>(); @@ -60,9 +49,6 @@ export class CloudExperimentsPlugin this.logger.get('metadata') ); - if (config.flag_overrides) { - this.flagOverrides = config.flag_overrides; - } const ldConfig = config.launch_darkly; if (!ldConfig?.client_id && !initializerContext.env.mode.dev) { // If the plugin is enabled, and it's in prod mode, launch_darkly must exist @@ -71,9 +57,6 @@ export class CloudExperimentsPlugin 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); } - if (ldConfig?.client_id) { - this.launchDarklyClient = new LaunchDarklyClient(ldConfig, this.kibanaVersion, this.logger); - } } /** @@ -82,83 +65,61 @@ export class CloudExperimentsPlugin * @param deps {@link CloudExperimentsPluginSetupDeps} */ public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) { - if (deps.cloud.isCloudEnabled && deps.cloud.deploymentId && this.launchDarklyClient) { - this.metadataService.setup({ - userId: deps.cloud.deploymentId, - kibanaVersion: this.kibanaVersion, - trialEndDate: deps.cloud.trialEndDate?.toISOString(), - isElasticStaff: deps.cloud.isElasticStaffOwned, - }); - } else { - this.launchDarklyClient?.cancel(); + initializeMetadata({ + metadataService: this.metadataService, + initializerContext: this.initializerContext, + cloud: deps.cloud, + featureFlags: core.featureFlags, + logger: this.logger, + }); + + const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); + if (launchDarklyOpenFeatureProvider) { + core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); } } /** - * Returns the contract {@link CloudExperimentsPluginStart} + * Sets the metadata service update hooks * @param core {@link CoreStart} + * @param deps {@link CloudExperimentsPluginStartDeps} */ - public start( - core: CoreStart, - { cloud, dataViews }: CloudExperimentsPluginStartDeps - ): CloudExperimentsPluginStart { - if (cloud.isCloudEnabled) { - this.metadataService.start({ - hasDataFetcher: async () => ({ hasData: await dataViews.hasData.hasUserDataView() }), - }); - - // We only subscribe to the user metadata updates if Cloud is enabled. - // This way, since the user is not identified, it cannot retrieve Feature Flags from LaunchDarkly when not running on Cloud. - this.metadataService.userMetadata$ - .pipe( - // Using concatMap to ensure we call the promised update in an orderly manner to avoid concurrency issues - concatMap( - async (userMetadata) => await this.launchDarklyClient?.updateUserMetadata(userMetadata) - ) - ) - .subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable - } - return { - getVariation: this.getVariation, - reportMetric: this.reportMetric, - }; + public start(core: CoreStart, { dataViews }: CloudExperimentsPluginStartDeps) { + this.metadataService.start({ + hasDataFetcher: async () => ({ has_data: await dataViews.hasData.hasUserDataView() }), + }); } /** * Cleans up and flush the sending queues. */ public stop() { - this.launchDarklyClient?.stop(); this.metadataService.stop(); } - private getVariation = async ( - featureFlagName: CloudExperimentsFeatureFlagNames, - defaultValue: Data - ): Promise => { - const configKey = FEATURE_FLAG_NAMES[featureFlagName]; - - // Apply overrides if they exist without asking LaunchDarkly. - if (this.flagOverrides && has(this.flagOverrides, configKey)) { - return get(this.flagOverrides, configKey, defaultValue) as Data; - } - - // Skip any action if no LD Client is defined - if (!this.launchDarklyClient) { - return defaultValue; - } - - return await this.launchDarklyClient.getVariation(configKey, defaultValue); - }; + /** + * Sets up the OpenFeature LaunchDarkly provider + * @private + */ + private createOpenFeatureProvider() { + const { launch_darkly: ldConfig } = this.initializerContext.config.get<{ + launch_darkly?: LaunchDarklyClientConfig; + }>(); - private reportMetric = ({ name, meta, value }: CloudExperimentsMetric): void => { - const metricName = METRIC_NAMES[name]; - this.launchDarklyClient?.reportMetric(metricName, meta, value); - if (this.isDev) { - // eslint-disable-next-line no-console - console.debug(`Reported experimentation metric ${metricName}`, { - experimentationMetric: { name, meta, value }, - }); - } - }; + if (!ldConfig) return; + + return new LaunchDarklyClientProvider(ldConfig.client_id, { + // logger: this.logger.get('launch-darkly'), + // Using basicLogger for now because we can't limit the level for now if we're using core's logger. + logger: basicLogger({ level: ldConfig.client_log_level }), + streaming: true, // Necessary to react to flag changes + application: { + id: 'kibana-browser', + version: + this.initializerContext.env.packageInfo.buildFlavor === 'serverless' + ? this.initializerContext.env.packageInfo.buildSha + : this.initializerContext.env.packageInfo.version, + }, + }); + } } diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.test.ts index 146de2c3ddc9ae..1d8d46e5cbf3f0 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.test.ts @@ -26,9 +26,6 @@ describe('cloudExperiments config', () => { client_id: '1234', client_log_level: 'none', }, - flag_overrides: { - 'my-plugin.my-feature-flag': 1234, - }, }; expect(config.schema.validate(cfg, ctx)).toStrictEqual({ ...cfg, @@ -37,31 +34,14 @@ describe('cloudExperiments config', () => { }); }); - test('it should allow any additional config (missing flag_overrides)', () => { - const cfg = { - enabled: false, - launch_darkly: { - sdk_key: 'sdk-1234', - client_id: '1234', - client_log_level: 'none', - }, - }; - expect(config.schema.validate(cfg, ctx)).toStrictEqual({ - ...cfg, - metadata_refresh_interval: moment.duration(1, 'h'), - }); - }); - test('it should allow any additional config (missing launch_darkly)', () => { const cfg = { enabled: false, - flag_overrides: { - 'my-plugin.my-feature-flag': 1234, - }, + metadata_refresh_interval: '1s', }; expect(config.schema.validate(cfg, ctx)).toStrictEqual({ ...cfg, - metadata_refresh_interval: moment.duration(1, 'h'), + metadata_refresh_interval: moment.duration(1, 's'), }); }); }); @@ -70,11 +50,8 @@ describe('cloudExperiments config', () => { describe('in dev mode', () => { const ctx = { dev: true }; test('in dev mode, it allows `launch_darkly` to be empty', () => { - expect( - config.schema.validate({ enabled: true, flag_overrides: { my_flag: 1 } }, ctx) - ).toStrictEqual({ + expect(config.schema.validate({ enabled: true }, ctx)).toStrictEqual({ enabled: true, - flag_overrides: { my_flag: 1 }, metadata_refresh_interval: moment.duration(1, 'h'), }); }); @@ -96,58 +73,6 @@ describe('cloudExperiments config', () => { `"[launch_darkly.sdk_key]: expected value of type [string] but got [undefined]"` ); }); - - test('in prod mode, it allows `flag_overrides` to be empty', () => { - expect( - config.schema.validate( - { - enabled: true, - launch_darkly: { - sdk_key: 'sdk-1234', - client_id: '1234', - }, - }, - ctx - ) - ).toStrictEqual({ - enabled: true, - launch_darkly: { - sdk_key: 'sdk-1234', - client_id: '1234', - client_log_level: 'none', - }, - metadata_refresh_interval: moment.duration(1, 'h'), - }); - }); - - test('in prod mode, it allows `flag_overrides` to be provided', () => { - expect( - config.schema.validate( - { - enabled: true, - launch_darkly: { - sdk_key: 'sdk-1234', - client_id: '1234', - }, - flag_overrides: { - my_flag: 123, - }, - }, - ctx - ) - ).toStrictEqual({ - enabled: true, - launch_darkly: { - sdk_key: 'sdk-1234', - client_id: '1234', - client_log_level: 'none', - }, - flag_overrides: { - my_flag: 123, - }, - metadata_refresh_interval: moment.duration(1, 'h'), - }); - }); }); }); }); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.ts index a5b5eeb88c2dd2..a1bcb5d53fd720 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/config.ts @@ -36,7 +36,6 @@ const configSchema = schema.object({ ), schema.maybe(launchDarklySchema) ), - flag_overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())), metadata_refresh_interval: schema.duration({ defaultValue: '1h' }), }); @@ -48,7 +47,6 @@ export const config: PluginConfigDescriptor = { client_id: true, client_log_level: true, }, - flag_overrides: true, metadata_refresh_interval: true, }, schema: configSchema, diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/index.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/index.ts deleted file mode 100644 index d298aad1ad6c16..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { LaunchDarklyClient, type LaunchDarklyUserMetadata } from './launch_darkly_client'; diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.mock.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.mock.ts deleted file mode 100644 index c8759ab59f6a93..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.mock.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { LDClient } from '@launchdarkly/node-server-sdk'; - -export function createLaunchDarklyClientMock(): jest.Mocked { - return { - waitForInitialization: jest.fn(), - variation: jest.fn(), - allFlagsState: jest.fn(), - track: jest.fn(), - flush: jest.fn(), - } as unknown as jest.Mocked; // Using casting because we only use these APIs. No need to declare everything. -} - -export const ldClientMock = createLaunchDarklyClientMock(); - -jest.doMock('@launchdarkly/node-server-sdk', () => ({ - init: () => ldClientMock, - basicLogger: jest.fn(), -})); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.ts deleted file mode 100644 index 0b928b74963979..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; -import { ldClientMock } from './launch_darkly_client.test.mock'; -import LaunchDarkly from '@launchdarkly/node-server-sdk'; -import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client'; - -describe('LaunchDarklyClient - server', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - - const config: LaunchDarklyClientConfig = { - sdk_key: 'fake-sdk-key', - client_id: 'fake-client-id', - client_log_level: 'debug', - kibana_version: 'version', - }; - - describe('constructor', () => { - let launchDarklyInitSpy: jest.SpyInstance; - - beforeEach(() => { - launchDarklyInitSpy = jest.spyOn(LaunchDarkly, 'init'); - }); - - afterEach(() => { - launchDarklyInitSpy.mockRestore(); - }); - - test('it initializes the LaunchDarkly client', async () => { - const logger = loggerMock.create(); - ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock); - - const client = new LaunchDarklyClient(config, logger); - expect(launchDarklyInitSpy).toHaveBeenCalledWith('fake-sdk-key', { - application: { id: 'kibana-server', version: 'version' }, - logger: undefined, // The method basicLogger is mocked without a return value - stream: false, - }); - expect(client).toHaveProperty('launchDarklyClient', ldClientMock); - await new Promise((resolve) => process.nextTick(resolve)); // wait for the waitForInitialization resolution - expect(logger.debug).toHaveBeenCalledWith('LaunchDarkly is initialized!'); - }); - - test('it initializes the LaunchDarkly client... and handles failure', async () => { - const logger = loggerMock.create(); - ldClientMock.waitForInitialization.mockRejectedValue( - new Error('Something went terribly wrong') - ); - - const client = new LaunchDarklyClient(config, logger); - expect(launchDarklyInitSpy).toHaveBeenCalledWith('fake-sdk-key', { - application: { id: 'kibana-server', version: 'version' }, - logger: undefined, // The method basicLogger is mocked without a return value - stream: false, - }); - expect(client).toHaveProperty('launchDarklyClient', ldClientMock); - await new Promise((resolve) => process.nextTick(resolve)); // wait for the waitForInitialization resolution - expect(logger.warn).toHaveBeenCalledWith( - 'Error initializing LaunchDarkly: Error: Something went terribly wrong' - ); - }); - }); - - describe('Public APIs', () => { - let client: LaunchDarklyClient; - let logger: MockedLogger; - const testUserMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' }; - - beforeEach(() => { - logger = loggerMock.create(); - ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock); - client = new LaunchDarklyClient(config, logger); - }); - - describe('updateUserMetadata', () => { - test('sets all properties at the root level, renaming userId to key (no nesting into custom)', () => { - expect(client).toHaveProperty('launchDarklyUser', undefined); - - const topFields = { - name: 'First Last', - firstName: 'First', - lastName: 'Last', - email: 'first.last@boring.co', - avatar: 'fake-blue-avatar', - ip: 'my-weird-ip', - country: 'distributed', - // intentionally adding this to make sure the code is overriding appropriately - kind: 'other kind', - key: 'other user', - }; - - const extraFields = { - other_field: 'my other custom field', - kibanaVersion: 'version', - }; - - client.updateUserMetadata({ userId: 'fake-user-id', ...topFields, ...extraFields }); - - expect(client).toHaveProperty('launchDarklyUser', { - ...topFields, - ...extraFields, - kind: 'user', - key: 'fake-user-id', - }); - }); - - test('sets a minimum amount of info', () => { - expect(client).toHaveProperty('launchDarklyUser', undefined); - - client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' }); - - expect(client).toHaveProperty('launchDarklyUser', { - kind: 'user', - key: 'fake-user-id', - kibanaVersion: 'version', - }); - }); - }); - - describe('getVariation', () => { - test('returns the default value if the user has not been defined', async () => { - await expect(client.getVariation('my-feature-flag', 123)).resolves.toStrictEqual(123); - expect(ldClientMock.variation).toHaveBeenCalledTimes(0); - }); - - test('calls the LaunchDarkly client when the user has been defined', async () => { - ldClientMock.variation.mockResolvedValue(1234); - client.updateUserMetadata(testUserMetadata); - await expect(client.getVariation('my-feature-flag', 123)).resolves.toStrictEqual(1234); - expect(ldClientMock.variation).toHaveBeenCalledTimes(1); - expect(ldClientMock.variation).toHaveBeenCalledWith( - 'my-feature-flag', - { kind: 'user', key: 'fake-user-id', kibanaVersion: 'version' }, - 123 - ); - }); - }); - - describe('reportMetric', () => { - test('does not call track if the user has not been defined', () => { - client.reportMetric('my-feature-flag', {}, 123); - expect(ldClientMock.track).toHaveBeenCalledTimes(0); - }); - - test('calls the LaunchDarkly client when the user has been defined', () => { - client.updateUserMetadata(testUserMetadata); - client.reportMetric('my-feature-flag', {}, 123); - expect(ldClientMock.track).toHaveBeenCalledTimes(1); - expect(ldClientMock.track).toHaveBeenCalledWith( - 'my-feature-flag', - { kind: 'user', key: 'fake-user-id', kibanaVersion: 'version' }, - {}, - 123 - ); - }); - }); - - describe('getAllFlags', () => { - test('returns the non-initialized state if the user has not been defined', async () => { - await expect(client.getAllFlags()).resolves.toStrictEqual({ - initialized: false, - flagNames: [], - flags: {}, - }); - expect(ldClientMock.allFlagsState).toHaveBeenCalledTimes(0); - }); - - test('calls the LaunchDarkly client when the user has been defined', async () => { - ldClientMock.allFlagsState.mockResolvedValue({ - valid: true, - allValues: jest.fn().mockReturnValue({ my_flag: '1234' }), - getFlagValue: jest.fn(), - getFlagReason: jest.fn(), - toJSON: jest.fn(), - }); - client.updateUserMetadata(testUserMetadata); - await expect(client.getAllFlags()).resolves.toStrictEqual({ - initialized: true, - flagNames: ['my_flag'], - flags: { my_flag: '1234' }, - }); - expect(ldClientMock.allFlagsState).toHaveBeenCalledTimes(1); - expect(ldClientMock.allFlagsState).toHaveBeenCalledWith({ - kind: 'user', - key: 'fake-user-id', - kibanaVersion: 'version', - }); - }); - }); - - describe('stop', () => { - test('flushes the events', async () => { - ldClientMock.flush.mockResolvedValue(); - expect(() => client.stop()).not.toThrow(); - expect(ldClientMock.flush).toHaveBeenCalledTimes(1); - await new Promise((resolve) => process.nextTick(resolve)); // wait for the flush resolution - expect(logger.error).not.toHaveBeenCalled(); - }); - - test('handles errors when flushing events', async () => { - const err = new Error('Something went terribly wrong'); - ldClientMock.flush.mockRejectedValue(err); - expect(() => client.stop()).not.toThrow(); - expect(ldClientMock.flush).toHaveBeenCalledTimes(1); - await new Promise((resolve) => process.nextTick(resolve)); // wait for the flush resolution - expect(logger.error).toHaveBeenCalledWith(err); - }); - }); - }); -}); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts deleted file mode 100644 index c6511302eb7b1e..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - type LDClient, - type LDFlagSet, - type LDLogLevel, - type LDSingleKindContext, -} from '@launchdarkly/node-server-sdk'; -import { init, basicLogger } from '@launchdarkly/node-server-sdk'; -import type { Logger } from '@kbn/core/server'; - -export interface LaunchDarklyClientConfig { - sdk_key: string; - client_id: string; - client_log_level: LDLogLevel; - kibana_version: string; -} - -export interface LaunchDarklyUserMetadata - extends Record { - userId: string; - // We are not collecting any of the above, but this is to match the LDUser first-level definition - name?: string; - firstName?: string; - lastName?: string; - email?: string; - avatar?: string; - ip?: string; - country?: string; -} - -export interface LaunchDarklyGetAllFlags { - initialized: boolean; - flags: LDFlagSet; - flagNames: string[]; -} - -export class LaunchDarklyClient { - private readonly launchDarklyClient: LDClient; - private launchDarklyUser?: LDSingleKindContext; - - constructor(ldConfig: LaunchDarklyClientConfig, private readonly logger: Logger) { - this.launchDarklyClient = init(ldConfig.sdk_key, { - application: { id: `kibana-server`, version: ldConfig.kibana_version }, - logger: basicLogger({ level: ldConfig.client_log_level }), - // For some reason, the stream API does not work in Kibana. `.waitForInitialization()` hangs forever (doesn't throw, neither logs any errors). - // Using polling for now until we resolve that issue. - // Relevant issue: https://github.com/launchdarkly/node-server-sdk/issues/132 - stream: false, - }); - this.launchDarklyClient.waitForInitialization().then( - () => this.logger.debug('LaunchDarkly is initialized!'), - (err) => this.logger.warn(`Error initializing LaunchDarkly: ${err}`) - ); - } - - public updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) { - const { userId, ...userMetadataWithoutUserId } = userMetadata; - this.launchDarklyUser = { - ...userMetadataWithoutUserId, - kind: 'user', - key: userId, - }; - } - - public async getVariation(configKey: string, defaultValue: Data): Promise { - if (!this.launchDarklyUser) return defaultValue; // Skip any action if no LD User is defined - await this.launchDarklyClient.waitForInitialization(); - return await this.launchDarklyClient.variation(configKey, this.launchDarklyUser, defaultValue); - } - - public reportMetric(metricName: string, meta?: unknown, value?: number): void { - if (!this.launchDarklyUser) return; // Skip any action if no LD User is defined - this.launchDarklyClient.track(metricName, this.launchDarklyUser, meta, value); - } - - public async getAllFlags(): Promise { - if (!this.launchDarklyUser) return { initialized: false, flagNames: [], flags: {} }; - // According to the docs, this method does not send analytics back to LaunchDarkly, so it does not provide false results - const flagsState = await this.launchDarklyClient.allFlagsState(this.launchDarklyUser); - const flags = flagsState.allValues(); - return { - initialized: flagsState.valid, - flags, - flagNames: Object.keys(flags), - }; - } - - public stop() { - this.launchDarklyClient?.flush().catch((err) => this.logger.error(err)); - } -} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/mocks.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/mocks.ts deleted file mode 100644 index 3fe1838815b27f..00000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/mocks.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { PublicMethodsOf } from '@kbn/utility-types'; -import { LaunchDarklyClient } from './launch_darkly_client'; - -function createLaunchDarklyClientMock(): jest.Mocked { - const launchDarklyClientMock: jest.Mocked> = { - updateUserMetadata: jest.fn(), - getVariation: jest.fn(), - getAllFlags: jest.fn(), - reportMetric: jest.fn(), - stop: jest.fn(), - }; - - return launchDarklyClientMock as jest.Mocked; -} - -export const launchDarklyClientMocks = { - launchDarklyClientMock: createLaunchDarklyClientMock(), - createLaunchDarklyClient: createLaunchDarklyClientMock, -}; diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.test.ts index 37989482dc31c1..0b52f8686bbc98 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { fakeSchedulers } from 'rxjs-marbles/jest'; import { coreMock } from '@kbn/core/server/mocks'; import { cloudMock } from '@kbn/cloud-plugin/server/mocks'; import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks'; @@ -16,9 +15,6 @@ import { import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; import { config } from './config'; import { CloudExperimentsPlugin } from './plugin'; -import { FEATURE_FLAG_NAMES } from '../common/constants'; -import { LaunchDarklyClient } from './launch_darkly_client'; -jest.mock('./launch_darkly_client'); describe('Cloud Experiments server plugin', () => { jest.useFakeTimers(); @@ -29,15 +25,13 @@ describe('Cloud Experiments server plugin', () => { }); describe('constructor', () => { - test('successfully creates a new plugin if provided an empty configuration', () => { + test('successfully creates a new when in dev mode plugin if provided an empty configuration', () => { const initializerContext = coreMock.createPluginInitializerContext(); initializerContext.env.mode.dev = true; // ensure it's true const plugin = new CloudExperimentsPlugin(initializerContext); expect(plugin).toHaveProperty('setup'); expect(plugin).toHaveProperty('start'); expect(plugin).toHaveProperty('stop'); - expect(plugin).toHaveProperty('flagOverrides', undefined); - expect(plugin).toHaveProperty('launchDarklyClient', undefined); }); test('fails if launch_darkly is not provided in the config and it is a non-dev environment', () => { @@ -47,24 +41,6 @@ describe('Cloud Experiments server plugin', () => { 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); }); - - test('it initializes the LaunchDarkly client', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - launch_darkly: { sdk_key: 'sdk-1234' }, - }); - const plugin = new CloudExperimentsPlugin(initializerContext); - expect(LaunchDarklyClient).toHaveBeenCalledTimes(1); - expect(plugin).toHaveProperty('launchDarklyClient', expect.any(LaunchDarklyClient)); - }); - - test('it initializes the flagOverrides property', () => { - const initializerContext = coreMock.createPluginInitializerContext({ - flag_overrides: { my_flag: '1234' }, - }); - initializerContext.env.mode.dev = true; // ensure it's true - const plugin = new CloudExperimentsPlugin(initializerContext); - expect(plugin).toHaveProperty('flagOverrides', { my_flag: '1234' }); - }); }); describe('setup', () => { @@ -75,7 +51,6 @@ describe('Cloud Experiments server plugin', () => { config.schema.validate( { launch_darkly: { sdk_key: 'sdk-1234', client_id: 'fake-client-id' }, - flag_overrides: { my_flag: '1234' }, }, { dev: true } ) @@ -87,221 +62,98 @@ describe('Cloud Experiments server plugin', () => { plugin.stop(); }); - test('returns the contract', () => { + test('registers the usage collector when available', () => { + const usageCollection = usageCollectionPluginMock.createSetupContract(); expect( plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup(), + usageCollection, }) ).toBeUndefined(); - }); - - test('registers the usage collector when available', () => { - const usageCollection = usageCollectionPluginMock.createSetupContract(); - plugin.setup(coreMock.createSetup(), { - cloud: cloudMock.createSetup(), - usageCollection, - }); expect(usageCollection.makeUsageCollector).toHaveBeenCalledTimes(1); expect(usageCollection.registerCollector).toHaveBeenCalledTimes(1); }); - test( - 'updates the user metadata on setup', - fakeSchedulers((advance) => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - const launchDarklyInstanceMock = ( - LaunchDarklyClient as jest.MockedClass - ).mock.instances[0]; - advance(100); // Remove the debounceTime effect - expect(launchDarklyInstanceMock.updateUserMetadata).toHaveBeenCalledWith({ - userId: 'deployment-id', - kibanaVersion: coreMock.createPluginInitializerContext().env.packageInfo.version, - isElasticStaff: true, - trialEndDate: expect.any(String), - }); - }) - ); + test('updates the user metadata on setup', async () => { + const coreSetupMock = coreMock.createSetup(); + plugin.setup(coreSetupMock, { + cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, + usageCollection: usageCollectionPluginMock.createSetupContract(), + }); + + const initializerContext = coreMock.createPluginInitializerContext(); + + await jest.advanceTimersByTimeAsync(100); // Remove the debounceTime effect + expect(coreSetupMock.featureFlags.appendContext).toHaveBeenCalledWith({ + kind: 'multi', + kibana: { + key: 'deployment-id', + offering: 'traditional', + version: initializerContext.env.packageInfo.version, + build_num: initializerContext.env.packageInfo.buildNum, + build_sha: initializerContext.env.packageInfo.buildSha, + build_sha_short: initializerContext.env.packageInfo.buildShaShort, + }, + organization: { + key: 'organization-id', + trial_end_date: expect.any(Date), + in_trial: false, + is_elastic_staff: true, + }, + }); + }); }); describe('start', () => { let plugin: CloudExperimentsPlugin; let dataViews: jest.Mocked; - let launchDarklyInstanceMock: jest.Mocked; - - const firstKnownFlag = Object.keys(FEATURE_FLAG_NAMES)[0] as keyof typeof FEATURE_FLAG_NAMES; beforeEach(() => { - jest.useRealTimers(); const initializerContext = coreMock.createPluginInitializerContext( config.schema.validate( { launch_darkly: { sdk_key: 'sdk-1234', client_id: 'fake-client-id' }, - flag_overrides: { [firstKnownFlag]: '1234' }, }, { dev: true } ) ); plugin = new CloudExperimentsPlugin(initializerContext); dataViews = createIndexPatternsStartMock(); - launchDarklyInstanceMock = (LaunchDarklyClient as jest.MockedClass) - .mock.instances[0] as jest.Mocked; }); afterEach(() => { plugin.stop(); - jest.useFakeTimers(); }); test('returns the contract', () => { - plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup() }); - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - expect(startContract).toStrictEqual( - expect.objectContaining({ - getVariation: expect.any(Function), - reportMetric: expect.any(Function), - }) - ); + plugin.setup(coreMock.createSetup(), { + cloud: cloudMock.createSetup(), + usageCollection: usageCollectionPluginMock.createSetupContract(), + }); + expect(plugin.start(coreMock.createStart(), { dataViews })).toBeUndefined(); }); test('triggers a userMetadataUpdate for `hasData`', async () => { - plugin.setup(coreMock.createSetup(), { + const coreSetup = coreMock.createSetup(); + plugin.setup(coreSetup, { cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, + usageCollection: usageCollectionPluginMock.createSetupContract(), }); dataViews.dataViewsServiceFactory.mockResolvedValue(dataViewsService); dataViewsService.hasUserDataView.mockResolvedValue(true); plugin.start(coreMock.createStart(), { dataViews }); // After scheduler kicks in... - await new Promise((resolve) => setTimeout(resolve, 200)); // Waiting for scheduler and debounceTime to complete (don't know why fakeScheduler didn't work here). - expect(launchDarklyInstanceMock.updateUserMetadata).toHaveBeenCalledWith( + await jest.advanceTimersByTimeAsync(100); + expect(coreSetup.featureFlags.appendContext).toHaveBeenCalledWith( expect.objectContaining({ - hasData: true, + kind: 'multi', + kibana: expect.objectContaining({ + has_data: true, + }), }) ); }); - - describe('getVariation', () => { - describe('with the client created', () => { - beforeEach(() => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - }); - - test('uses the flag overrides to respond early', async () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( - '1234' - ); - }); - - test('calls the client', async () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - launchDarklyInstanceMock.getVariation.mockResolvedValue('12345'); - await expect( - startContract.getVariation( - // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES - 'some-random-flag', - 123 - ) - ).resolves.toStrictEqual('12345'); - expect(launchDarklyInstanceMock.getVariation).toHaveBeenCalledWith( - undefined, // it couldn't find it in FEATURE_FLAG_NAMES - 123 - ); - }); - }); - - describe('with the client not created (missing LD settings)', () => { - beforeEach(() => { - const initializerContext = coreMock.createPluginInitializerContext( - config.schema.validate( - { - flag_overrides: { [firstKnownFlag]: '1234' }, - }, - { dev: true } - ) - ); - plugin = new CloudExperimentsPlugin(initializerContext); - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: false }, - }); - }); - - test('uses the flag overrides to respond early', async () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual( - '1234' - ); - }); - - test('returns the default value without calling the client', async () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - await expect( - startContract.getVariation( - // @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES - 'some-random-flag', - 123 - ) - ).resolves.toStrictEqual(123); - }); - }); - }); - - describe('reportMetric', () => { - describe('with the client created', () => { - beforeEach(() => { - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, - }); - }); - - test('calls LaunchDarklyClient.reportMetric', () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - startContract.reportMetric({ - // @ts-expect-error We only allow existing flags in METRIC_NAMES - name: 'my-flag', - meta: {}, - value: 1, - }); - expect(launchDarklyInstanceMock.reportMetric).toHaveBeenCalledWith( - undefined, // it couldn't find it in METRIC_NAMES - {}, - 1 - ); - }); - }); - - describe('with the client not created (missing LD settings)', () => { - beforeEach(() => { - const initializerContext = coreMock.createPluginInitializerContext( - config.schema.validate( - { - flag_overrides: { [firstKnownFlag]: '1234' }, - }, - { dev: true } - ) - ); - plugin = new CloudExperimentsPlugin(initializerContext); - plugin.setup(coreMock.createSetup(), { - cloud: { ...cloudMock.createSetup(), isCloudEnabled: false }, - }); - }); - - test('does not call LaunchDarklyClient.reportMetric because the client is not there', () => { - const startContract = plugin.start(coreMock.createStart(), { dataViews }); - startContract.reportMetric({ - // @ts-expect-error We only allow existing flags in METRIC_NAMES - name: 'my-flag', - meta: {}, - value: 1, - }); - expect(plugin).toHaveProperty('launchDarklyClient', undefined); - }); - }); - }); }); describe('stop', () => { @@ -312,7 +164,6 @@ describe('Cloud Experiments server plugin', () => { config.schema.validate( { launch_darkly: { sdk_key: 'sdk-1234', client_id: 'fake-client-id' }, - flag_overrides: { my_flag: '1234' }, }, { dev: true } ) @@ -321,18 +172,11 @@ describe('Cloud Experiments server plugin', () => { const dataViews = createIndexPatternsStartMock(); plugin.setup(coreMock.createSetup(), { cloud: { ...cloudMock.createSetup(), isCloudEnabled: true }, + usageCollection: usageCollectionPluginMock.createSetupContract(), }); plugin.start(coreMock.createStart(), { dataViews }); }); - test('stops the LaunchDarkly client', () => { - plugin.stop(); - const launchDarklyInstanceMock = ( - LaunchDarklyClient as jest.MockedClass - ).mock.instances[0] as jest.Mocked; - expect(launchDarklyInstanceMock.stop).toHaveBeenCalledTimes(1); - }); - test('stops the Metadata Service', () => { // eslint-disable-next-line dot-notation const metadataServiceStopSpy = jest.spyOn(plugin['metadataService'], 'stop'); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts index 834784a11f2c5f..fa9de11b0dfc9d 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts @@ -12,26 +12,20 @@ import type { Plugin, Logger, } from '@kbn/core/server'; -import { get, has } from 'lodash'; -import type { LogMeta } from '@kbn/logging'; +import { map } from 'rxjs'; +import { OpenFeature } from '@openfeature/server-sdk'; +import { LaunchDarklyProvider } from '@launchdarkly/openfeature-node-server'; +import type { LogLevelId } from '@kbn/logging'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server/types'; -import { filter, map } from 'rxjs'; -import { MetadataService } from '../common/metadata_service'; -import { LaunchDarklyClient } from './launch_darkly_client'; +import { initializeMetadata, MetadataService } from '../common/metadata_service'; import { registerUsageCollector } from './usage'; import type { CloudExperimentsConfigType } from './config'; -import type { - CloudExperimentsFeatureFlagNames, - CloudExperimentsMetric, - CloudExperimentsPluginStart, -} from '../common'; -import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants'; interface CloudExperimentsPluginSetupDeps { cloud: CloudSetup; - usageCollection?: UsageCollectionSetup; + usageCollection: UsageCollectionSetup; } interface CloudExperimentsPluginStartDeps { @@ -39,11 +33,9 @@ interface CloudExperimentsPluginStartDeps { } export class CloudExperimentsPlugin - implements Plugin + implements Plugin { private readonly logger: Logger; - private readonly launchDarklyClient?: LaunchDarklyClient; - private readonly flagOverrides?: Record; private readonly metadataService: MetadataService; constructor(private readonly initializerContext: PluginInitializerContext) { @@ -55,9 +47,6 @@ export class CloudExperimentsPlugin this.logger.get('metadata') ); - if (config.flag_overrides) { - this.flagOverrides = config.flag_overrides; - } const ldConfig = config.launch_darkly; // If the plugin is enabled and no flag_overrides are provided (dev mode only), launch_darkly must exist if (!ldConfig && !initializerContext.env.mode.dev) { // If the plugin is enabled, and it's in prod mode, launch_darkly must exist @@ -66,87 +55,70 @@ export class CloudExperimentsPlugin 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); } - if (ldConfig) { - this.launchDarklyClient = new LaunchDarklyClient( - { - ...ldConfig, - kibana_version: initializerContext.env.packageInfo.version, - }, - this.logger.get('launch_darkly') - ); - } } public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) { - if (deps.usageCollection) { - registerUsageCollector(deps.usageCollection, () => ({ - launchDarklyClient: this.launchDarklyClient, - })); - } + // Ideally we should have something like this for the browser as well. + core.logging.configure( + this.initializerContext.config.create().pipe( + map(({ launch_darkly: { client_log_level: clientLogLevel = 'none' } = {} }) => { + const logLevel = clientLogLevel.replace('none', 'off') as LogLevelId; + return { loggers: [{ name: 'launch-darkly', level: logLevel, appenders: [] }] }; + }) + ) + ); + + initializeMetadata({ + metadataService: this.metadataService, + initializerContext: this.initializerContext, + cloud: deps.cloud, + featureFlags: core.featureFlags, + logger: this.logger, + }); - if (deps.cloud.isCloudEnabled && deps.cloud.deploymentId) { - this.metadataService.setup({ - // We use the Cloud Deployment ID as the userId in the Cloud Experiments - userId: deps.cloud.deploymentId, - kibanaVersion: this.initializerContext.env.packageInfo.version, - trialEndDate: deps.cloud.trialEndDate?.toISOString(), - isElasticStaff: deps.cloud.isElasticStaffOwned, - }); - - // We only subscribe to the user metadata updates if Cloud is enabled. - // This way, since the user is not identified, it cannot retrieve Feature Flags from LaunchDarkly when not running on Cloud. - this.metadataService.userMetadata$ - .pipe( - filter(Boolean), // Filter out undefined - map((userMetadata) => this.launchDarklyClient?.updateUserMetadata(userMetadata)) - ) - .subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable + const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); + if (launchDarklyOpenFeatureProvider) { + core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); } + + registerUsageCollector(deps.usageCollection, () => ({ + launchDarklyClient: launchDarklyOpenFeatureProvider?.getClient(), + currentContext: OpenFeature.getContext(), + })); } public start(core: CoreStart, deps: CloudExperimentsPluginStartDeps) { this.metadataService.start({ hasDataFetcher: async () => await this.addHasDataMetadata(core, deps.dataViews), }); - return { - getVariation: this.getVariation, - reportMetric: this.reportMetric, - }; } public stop() { - this.launchDarklyClient?.stop(); this.metadataService.stop(); } - private getVariation = async ( - featureFlagName: CloudExperimentsFeatureFlagNames, - defaultValue: Data - ): Promise => { - const configKey = FEATURE_FLAG_NAMES[featureFlagName]; - // Apply overrides if they exist without asking LaunchDarkly. - if (this.flagOverrides && has(this.flagOverrides, configKey)) { - return get(this.flagOverrides, configKey, defaultValue) as Data; - } - if (!this.launchDarklyClient) return defaultValue; - return await this.launchDarklyClient.getVariation(configKey, defaultValue); - }; - - private reportMetric = ({ name, meta, value }: CloudExperimentsMetric): void => { - const metricName = METRIC_NAMES[name]; - this.launchDarklyClient?.reportMetric(metricName, meta, value); - this.logger.debug<{ experimentationMetric: CloudExperimentsMetric } & LogMeta>( - `Reported experimentation metric ${metricName}`, - { - experimentationMetric: { name, meta, value }, - } - ); - }; + private createOpenFeatureProvider() { + const { launch_darkly: ldConfig } = + this.initializerContext.config.get(); + + if (!ldConfig) return; + + return new LaunchDarklyProvider(ldConfig.sdk_key, { + logger: this.logger.get('launch-darkly'), + application: { + id: 'kibana-server', + version: + this.initializerContext.env.packageInfo.buildFlavor === 'serverless' + ? this.initializerContext.env.packageInfo.buildSha + : this.initializerContext.env.packageInfo.version, + }, + }); + } private async addHasDataMetadata( core: CoreStart, dataViews: DataViewsServerPluginStart - ): Promise<{ hasData: boolean }> { + ): Promise<{ has_data: boolean }> { const dataViewsService = await dataViews.dataViewsServiceFactory( core.savedObjects.createInternalRepository(), core.elasticsearch.client.asInternalUser, @@ -154,7 +126,7 @@ export class CloudExperimentsPlugin true // Ignore capabilities checks ); return { - hasData: await dataViewsService.hasUserDataView(), + has_data: await dataViewsService.hasUserDataView(), }; } } diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.test.ts index ab18c2dbed613f..0236ce9e956923 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.test.ts @@ -15,7 +15,7 @@ import { type LaunchDarklyEntitiesGetter, type Usage, } from './register_usage_collector'; -import { launchDarklyClientMocks } from '../launch_darkly_client/mocks'; +import type { LDClient } from '@launchdarkly/node-server-sdk'; describe('cloudExperiments usage collector', () => { let collector: Collector; @@ -43,17 +43,21 @@ describe('cloudExperiments usage collector', () => { }); test('should return all the flags returned by the client', async () => { - const launchDarklyClient = launchDarklyClientMocks.createLaunchDarklyClient(); - getLaunchDarklyEntitiesMock.mockReturnValueOnce({ launchDarklyClient }); - - launchDarklyClient.getAllFlags.mockResolvedValueOnce({ - initialized: true, - flags: { + const allFlagStateImplementation: jest.Mocked = async () => ({ + valid: true, + allValues: jest.fn().mockReturnValue({ 'my-plugin.my-feature-flag': true, 'my-plugin.my-other-feature-flag': 22, - }, - flagNames: ['my-plugin.my-feature-flag', 'my-plugin.my-other-feature-flag'], + }), + getFlagReason: jest.fn(), + getFlagValue: jest.fn(), + toJSON: jest.fn(), }); + const launchDarklyClient: jest.Mocked = { + allFlagsState: jest.fn().mockImplementation(allFlagStateImplementation), + } as unknown as jest.Mocked; // Force-casting here because we don't need to mock the entire client + + getLaunchDarklyEntitiesMock.mockReturnValueOnce({ launchDarklyClient, currentContext: {} }); await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({ flagNames: ['my-plugin.my-feature-flag', 'my-plugin.my-other-feature-flag'], diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.ts index 8522a44a962e05..599ba431b4a3d4 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/usage/register_usage_collector.ts @@ -5,8 +5,9 @@ * 2.0. */ +import type { EvaluationContext } from '@kbn/core-feature-flags-server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; -import type { LaunchDarklyClient } from '../launch_darkly_client'; +import type { LDClient, LDMultiKindContext } from '@launchdarkly/node-server-sdk'; export interface Usage { initialized: boolean; @@ -15,7 +16,8 @@ export interface Usage { } export type LaunchDarklyEntitiesGetter = () => { - launchDarklyClient?: LaunchDarklyClient; + launchDarklyClient?: LDClient; + currentContext: EvaluationContext; }; export function registerUsageCollector( @@ -50,10 +52,23 @@ export function registerUsageCollector( }, }, fetch: async () => { - const { launchDarklyClient } = getLaunchDarklyEntities(); + const { launchDarklyClient, currentContext } = getLaunchDarklyEntities(); if (!launchDarklyClient) return { initialized: false, flagNames: [], flags: {} }; - return await launchDarklyClient.getAllFlags(); + return await getAllFlags(launchDarklyClient, currentContext); }, }) ); } + +async function getAllFlags( + launchDarklyClient: LDClient, + currentContext: EvaluationContext +): Promise { + const flagsState = await launchDarklyClient.allFlagsState(currentContext as LDMultiKindContext); + const flags = flagsState.allValues(); + return { + initialized: flagsState.valid, + flags, + flagNames: Object.keys(flags), + }; +} diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json b/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json index e1c6ed7b04539a..d47131016228d9 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json @@ -18,7 +18,11 @@ "@kbn/config-schema", "@kbn/logging", "@kbn/logging-mocks", - "@kbn/utility-types", + "@kbn/core-plugins-browser", + "@kbn/core-plugins-server", + "@kbn/core-feature-flags-browser", + "@kbn/core-feature-flags-server", + "@kbn/config", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/fleet/.storybook/context/index.tsx b/x-pack/plugins/fleet/.storybook/context/index.tsx index 3b2d23e59291a4..67ed1c8aa68455 100644 --- a/x-pack/plugins/fleet/.storybook/context/index.tsx +++ b/x-pack/plugins/fleet/.storybook/context/index.tsx @@ -19,6 +19,7 @@ import type { UserProfileServiceStart, } from '@kbn/core/public'; import { CoreScopedHistory } from '@kbn/core/public'; +import { coreFeatureFlagsMock } from '@kbn/core/public/mocks'; import { getStorybookContextProvider } from '@kbn/custom-integrations-plugin/storybook'; import type { DashboardStart } from '@kbn/dashboard-plugin/public'; @@ -73,6 +74,7 @@ export const StorybookContext: React.FC<{ }, application: getApplication(), executionContext: getExecutionContext(), + featureFlags: coreFeatureFlagsMock.createStart(), chrome: getChrome(), cloud: { ...getCloud({ isCloudEnabled }), diff --git a/x-pack/plugins/observability_solution/observability_onboarding/README.md b/x-pack/plugins/observability_solution/observability_onboarding/README.md index 1284f71750e410..ad29a8a0c90a64 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/README.md +++ b/x-pack/plugins/observability_solution/observability_onboarding/README.md @@ -8,13 +8,4 @@ To run the stateful onboarding flows start Kibana as usual. ## Serverless onboarding -To run the experimental serverless onboarding flows add the following settings to `kibana.dev.yml`: - -```yml -xpack.cloud_integrations.experiments.enabled: true -xpack.cloud_integrations.experiments.flag_overrides: - "observability_onboarding.experimental_onboarding_flow_enabled": true - -``` - -Then start Kibana using `yarn serverless-oblt`. +To run the serverless onboarding flows start Kibana using `yarn serverless-oblt`. diff --git a/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc b/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc index fd4e955b9bd955..859f9539bd9faf 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc @@ -16,7 +16,7 @@ "fleet", "customIntegrations" ], - "optionalPlugins": ["cloud", "cloudExperiments", "usageCollection"], + "optionalPlugins": ["cloud", "usageCollection"], "requiredBundles": ["kibanaReact"], "extraPublicDirs": ["common"] } diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts index 514a4fcc94049b..97850fc5ff47b8 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts @@ -21,7 +21,6 @@ import { Plugin, PluginInitializerContext, } from '@kbn/core/public'; -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; import { DiscoverSetup, DiscoverStart } from '@kbn/discover-plugin/public'; @@ -62,7 +61,6 @@ export interface ObservabilityOnboardingPluginStartDeps { fleet: FleetStart; cloud?: CloudStart; usageCollection?: UsageCollectionStart; - cloudExperiments?: CloudExperimentsPluginStart; } export type ObservabilityOnboardingContextValue = CoreStart & diff --git a/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json index 12a908624cfd92..8730f85b5943f7 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json @@ -34,7 +34,6 @@ "@kbn/deeplinks-observability", "@kbn/fleet-plugin", "@kbn/shared-ux-link-redirect-app", - "@kbn/cloud-experiments-plugin", "@kbn/home-sample-data-tab", "@kbn/react-kibana-context-render", "@kbn/react-kibana-context-theme", diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index e5840a6662e79e..075da90b44a0f7 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -57,7 +57,6 @@ "entityManager" ], "optionalPlugins": [ - "cloudExperiments", "encryptedSavedObjects", "fleet", "ml", diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 27a6d28418e9ec..07a9220b3dfa4f 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -44,7 +44,6 @@ import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; import { noCasesPermissions } from '../../../cases_test_utils'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; import { mockApm } from '../apm/service.mock'; -import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks'; import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; @@ -124,7 +123,6 @@ export const createStartServicesMock = ( const dataViewServiceMock = dataViewPluginMocks.createStartContract(); cases.helpers.canUseCases.mockReturnValue(noCasesPermissions()); const triggersActionsUi = triggersActionsUiMock.createStart(); - const cloudExperiments = cloudExperimentsMock.createStartMock(); const guidedOnboarding = guidedOnboardingMock.createStart(); const cloud = cloudMock.createStart(); const mockSetHeaderActionMenu = jest.fn(); @@ -238,7 +236,6 @@ export const createStartServicesMock = ( fetchAllLiveQueries: jest.fn().mockReturnValue({ data: { data: { items: [] } } }), }, triggersActionsUi, - cloudExperiments, guidedOnboarding, cloud: { ...cloud, diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 42c332d5f84493..ca144c21d7847b 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -45,7 +45,6 @@ import type { SavedObjectTaggingOssPluginStart, } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { ThreatIntelligencePluginStart } from '@kbn/threat-intelligence-plugin/public'; -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public'; import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; @@ -143,7 +142,6 @@ export interface StartPlugins { cloudDefend: CloudDefendPluginStart; cloudSecurityPosture: CspClientPluginStart; threatIntelligence: ThreatIntelligencePluginStart; - cloudExperiments?: CloudExperimentsPluginStart; dataViews: DataViewsServicePublic; fieldFormats: FieldFormatsStartCommon; discover: DiscoverStart; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index b2df523f67d6b2..e6ec61c44d89e9 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -39,7 +39,6 @@ "@kbn/actions-plugin", "@kbn/alerting-plugin", "@kbn/cases-plugin", - "@kbn/cloud-experiments-plugin", "@kbn/cloud-security-posture-plugin", "@kbn/encrypted-saved-objects-plugin", "@kbn/features-plugin", diff --git a/yarn.lock b/yarn.lock index 13852c94782c1c..9ef2287fc16df8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3994,6 +3994,30 @@ version "0.0.0" uid "" +"@kbn/core-feature-flags-browser-internal@link:packages/core/feature-flags/core-feature-flags-browser-internal": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-browser-mocks@link:packages/core/feature-flags/core-feature-flags-browser-mocks": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-browser@link:packages/core/feature-flags/core-feature-flags-browser": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-server-internal@link:packages/core/feature-flags/core-feature-flags-server-internal": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-server-mocks@link:packages/core/feature-flags/core-feature-flags-server-mocks": + version "0.0.0" + uid "" + +"@kbn/core-feature-flags-server@link:packages/core/feature-flags/core-feature-flags-server": + version "0.0.0" + uid "" + "@kbn/core-history-block-plugin@link:test/plugin_functional/plugins/core_history_block": version "0.0.0" uid "" @@ -5078,6 +5102,10 @@ version "0.0.0" uid "" +"@kbn/feature-flags-example-plugin@link:examples/feature_flags_example": + version "0.0.0" + uid "" + "@kbn/feature-usage-test-plugin@link:x-pack/test/plugin_api_integration/plugins/feature_usage_test": version "0.0.0" uid "" @@ -7331,6 +7359,11 @@ https-proxy-agent "^5.0.1" launchdarkly-eventsource "2.0.3" +"@launchdarkly/openfeature-node-server@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@launchdarkly/openfeature-node-server/-/openfeature-node-server-1.0.0.tgz#09abebb56608e729049c3ebbd2373ce0ea25121d" + integrity sha512-4O4bQSqM+9BUZo8L+rQkxUdrv3sqC8vGcC0U0yBvELXmd9Q8jJZkY+7+idcx/zJsInYwnfmS0TUA4YeOyQw89A== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" @@ -7978,6 +8011,28 @@ resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== +"@openfeature/core@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@openfeature/core/-/core-1.3.0.tgz#59e98813fa3878402de7b9529cec1734597f9be7" + integrity sha512-Z2TiqfC4zoiCB/JMzIrzRrdDYdfOCGjI2MDgNHDEwA/k3y5IZANFkNAc/nhfof/QrmOy0HjQtvjRLnEW8urqJQ== + +"@openfeature/launchdarkly-client-provider@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@openfeature/launchdarkly-client-provider/-/launchdarkly-client-provider-0.3.0.tgz#47ad29671529595314fdb9497d078be0a744e006" + integrity sha512-iFe27RbuUxv4hDGJDmWJnxs5gpzU2d1xTxrGu/8z0gcbtXUAaYM6s4kglf63V2QzWV/Grot6P6bwSLlqeSDwMw== + dependencies: + lodash.isempty "4.4.0" + +"@openfeature/server-sdk@^1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@openfeature/server-sdk/-/server-sdk-1.15.0.tgz#f10e8284e6fbc010d40cc9515227456eb3a1620f" + integrity sha512-NEdVg5YuUNrCTNtLOg2f37QHCCGmKtfPEkFCsbwnJ3PQ5Gkii3Qufhr17LhbRqKOahqMBuNiMebQ3n1p8ty6Sg== + +"@openfeature/web-sdk@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@openfeature/web-sdk/-/web-sdk-1.2.1.tgz#6069cedfd1ba7bd88ea47e7afb0d2892d1c891e5" + integrity sha512-4Yz6zQA8/zwFUjKhvgyhIscywkLuDLOpzy//+GdMpSgvC1VsyifFf0p0ISMMLXlQmYZxVLamhL6jAnVge8VyEw== + "@opentelemetry/api-metrics@0.31.0", "@opentelemetry/api-metrics@^0.31.0": version "0.31.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api-metrics/-/api-metrics-0.31.0.tgz#0ed4cf4d7c731f968721c2b303eaf5e9fd42f736" @@ -12843,9 +12898,9 @@ async@^1.4.2: integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= async@^3.2.0, async@^3.2.3, async@^3.2.4: - version "3.2.4" - resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" - integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== asynckit@^0.4.0: version "0.4.0" @@ -14417,16 +14472,16 @@ clone-stats@^1.0.0: resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA= -clone@2.x, clone@^2.1.1, clone@^2.1.2, clone@~2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" - integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= - clone@^1.0.2, clone@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= +clone@^2.1.1, clone@^2.1.2, clone@~2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= + cloneable-readable@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.2.tgz#d591dee4a8f8bc15da43ce97dceeba13d43e2a65" @@ -22079,11 +22134,6 @@ language-tags@=1.0.5: dependencies: language-subtag-registry "~0.3.2" -launchdarkly-eventsource@1.4.4: - version "1.4.4" - resolved "https://registry.yarnpkg.com/launchdarkly-eventsource/-/launchdarkly-eventsource-1.4.4.tgz#fa595af8602e487c61520787170376c6a1104459" - integrity sha512-GL+r2Y3WccJlhFyL2buNKel+9VaMnYpbE/FfCkOST5jSNSFodahlxtGyrE8o7R+Qhobyq0Ree4a7iafJDQi9VQ== - launchdarkly-eventsource@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/launchdarkly-eventsource/-/launchdarkly-eventsource-2.0.3.tgz#8a7b8da5538153f438f7d452b1c87643d900f984" @@ -22106,19 +22156,6 @@ launchdarkly-js-sdk-common@5.3.0: fast-deep-equal "^2.0.1" uuid "^8.0.0" -launchdarkly-node-server-sdk@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/launchdarkly-node-server-sdk/-/launchdarkly-node-server-sdk-7.0.3.tgz#d7a8b996d992b0ca5d4972db5df1ae49332b094c" - integrity sha512-uSkBezAiQ9nwv8N6CmI7OmyJ9e3xpueJzYOso8+5vMf7VtBtPjz6RRsUkUsSzUDo7siclmW8USjCwqn9aX2EbQ== - dependencies: - async "^3.2.4" - launchdarkly-eventsource "1.4.4" - lru-cache "^6.0.0" - node-cache "^5.1.0" - semver "^7.5.4" - tunnel "0.0.6" - uuid "^8.3.2" - lazy-ass@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513" @@ -22406,7 +22443,7 @@ lodash.isboolean@^3.0.3: resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== -lodash.isempty@^4.4.0: +lodash.isempty@4.4.0, lodash.isempty@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" integrity sha1-b4bL7di+TsmHvpqvM8loTbGzHn4= @@ -24044,13 +24081,6 @@ node-addon-api@^6.1.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== -node-cache@^5.1.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" - integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== - dependencies: - clone "2.x" - node-diff3@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/node-diff3/-/node-diff3-3.1.2.tgz#49df8d821dc9cbab87bfd6182171d90169613a97" @@ -30757,7 +30787,7 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" -tunnel@0.0.6, tunnel@^0.0.6: +tunnel@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==