From 478d593b8b4f8e9ab01aab23785d1cacd3d72d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 15 Jul 2024 14:25:50 +0200 Subject: [PATCH 01/41] [Feature Flags Service] Hello world :wave: --- package.json | 5 + .../README.md | 3 + .../index.ts | 9 ++ .../jest.config.js | 13 ++ .../kibana.jsonc | 5 + .../package.json | 6 + .../src/feature_flags_service.ts | 102 +++++++++++++++ .../tsconfig.json | 19 +++ .../core-feature-flags-browser/README.md | 3 + .../core-feature-flags-browser/index.ts | 9 ++ .../core-feature-flags-browser/jest.config.js | 13 ++ .../core-feature-flags-browser/kibana.jsonc | 5 + .../core-feature-flags-browser/package.json | 6 + .../core-feature-flags-browser/src/types.ts | 116 ++++++++++++++++++ .../core-feature-flags-browser/tsconfig.json | 19 +++ .../src/internal_core_setup.ts | 2 + .../src/internal_core_start.ts | 2 + .../core-lifecycle-browser/src/core_setup.ts | 3 + .../core-lifecycle-browser/src/core_start.ts | 3 + .../src/plugin_context.ts | 2 + .../src/core_system.ts | 9 ++ renovate.json | 9 ++ src/core/public/index.ts | 5 + tsconfig.base.json | 4 + yarn.lock | 23 ++++ 25 files changed, 395 insertions(+) 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.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/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/jest.config.js 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 diff --git a/package.json b/package.json index 333b5350245cb..62d3808f73213 100644 --- a/package.json +++ b/package.json @@ -276,6 +276,8 @@ "@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-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", @@ -961,6 +963,9 @@ "@mapbox/mapbox-gl-supported": "2.0.1", "@mapbox/vector-tile": "1.3.1", "@mswjs/http-middleware": "^0.10.1", + "@openfeature/core": "^1.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", 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 0000000000000..33221f2df6286 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/README.md @@ -0,0 +1,3 @@ +# @kbn/core-feature-flags-browser-internal + +Empty package generated by @kbn/generate 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 0000000000000..b2cdfd473fc5f --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts @@ -0,0 +1,9 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +export { FeatureFlagsService } 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 0000000000000..39f77fe7adebf --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 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 0000000000000..eea26603cbe06 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "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 0000000000000..93e5c87660c8e --- /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": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file 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 0000000000000..58da623a4da33 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts @@ -0,0 +1,102 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import type { CoreContext } from '@kbn/core-base-browser-internal'; +import type { + EvaluationContext, + FeatureFlagsSetup, + FeatureFlagsStart, +} from '@kbn/core-feature-flags-browser'; +import type { Logger } from '@kbn/logging'; +import { apm } from '@elastic/apm-rum'; +import { type Client, OpenFeature } from '@openfeature/web-sdk'; + +export class FeatureFlagsService { + private readonly featureFlagsClient: Client; + private readonly isServerless: boolean; + private readonly logger: Logger; + private isProviderReadyPromise?: Promise; + + constructor(core: CoreContext) { + this.logger = core.logger.get('feature-flags-service'); + this.isServerless = core.env.packageInfo.buildFlavor === 'serverless'; + this.featureFlagsClient = OpenFeature.getClient(); + OpenFeature.setLogger(this.logger.get('open-feature')); + } + + /** + * Setup lifecycle method + */ + public setup(): FeatureFlagsSetup { + return { + setProvider: (provider) => { + this.isProviderReadyPromise = OpenFeature.setProviderAndWait(provider); + }, + setContext: (contextToAppend) => this.setContext(contextToAppend), + }; + } + + /** + * Start lifecycle method + */ + public async start(): Promise { + // Adding a timeout here to avoid hanging the start for too long if the provider is unresponsive + await Promise.race([ + this.isProviderReadyPromise, + await new Promise((resolve) => setTimeout(resolve, 2 * 1000)).then(() => { + this.logger.warn('The feature flags provider took too long to initialize'); + }), + ]); + + return { + addHandler: this.featureFlagsClient.addHandler.bind(this.featureFlagsClient), + setContext: (contextToAppend) => this.setContext(contextToAppend), + getBooleanValue: (flagName: string, fallbackValue: boolean) => { + // TODO: intercept with config overrides + const value = this.featureFlagsClient.getBooleanValue(flagName, fallbackValue); + apm.addLabels({ [`flag_${flagName}`]: value }); + // TODO: increment usage counter + return value; + }, + getStringValue: (flagName: string, fallbackValue: string) => { + // TODO: intercept with config overrides + const value = this.featureFlagsClient.getStringValue(flagName, fallbackValue); + apm.addLabels({ [`flag_${flagName}`]: value }); + // TODO: increment usage counter + return value; + }, + getNumberValue: (flagName: string, fallbackValue: number) => { + // TODO: intercept with config overrides + const value = this.featureFlagsClient.getNumberValue(flagName, fallbackValue); + apm.addLabels({ [`flag_${flagName}`]: value }); + // TODO: increment usage counter + return value; + }, + }; + } + + /** + * Stop lifecycle method + */ + public async stop() { + await OpenFeature.close(); + } + + /** + * Formats the provided context to fulfill the expected multi-context structure. + * @param contextToAppend The {@link EvaluationContext} to append. + * @private + */ + private async setContext(contextToAppend: EvaluationContext): Promise { + // If no kind provided, default to the project|deployment level. + const { kind = this.isServerless ? 'project' : 'deployment', ...rest } = contextToAppend; + // Format the context to fulfill the expected multi-context structure + const context = kind !== 'multi' ? { kind: 'multi', [kind]: rest } : contextToAppend; + await OpenFeature.setContext(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 0000000000000..5c989599ec9ad --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} 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 0000000000000..5a6743adc5a09 --- /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 0000000000000..450e26b5ad1f7 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/index.ts @@ -0,0 +1,9 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +export type { EvaluationContext, FeatureFlagsSetup, FeatureFlagsStart } from './src/types'; diff --git a/packages/core/feature-flags/core-feature-flags-browser/jest.config.js b/packages/core/feature-flags/core-feature-flags-browser/jest.config.js new file mode 100644 index 0000000000000..9fbcd085641fc --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/core/feature-flags/core-feature-flags-browser'], +}; 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 0000000000000..ececde79a7549 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "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 0000000000000..097468f5eba2f --- /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": "SSPL-1.0 OR Elastic License 2.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 0000000000000..c24fafbc8fb24 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts @@ -0,0 +1,116 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import type { Provider } from '@openfeature/web-sdk'; +import { + ClientProviderEvents, + type EvaluationContext as OpenFeatureEvaluationContext, + type Eventing, +} from '@openfeature/core'; + +/** + * The evaluation context to use when retrieving the flags. + * @public + */ +export type EvaluationContext = OpenFeatureEvaluationContext & { + /** + * We use multi-context so that we can apply segmentation rules at different levels (organization/project/deployment). + * Kind helps us specify which sub-context should receive the new attributes. + * If no `kind` is provided, it defaults to `project`|`deployment`, depending on the offering (Serverless vs. Traditional). + * @public + * + * @example + * { + * kind: 'multi', + * organization: { + * key: 1234, + * in_trial: true, + * }, + * project|deployment: { + * key: 12345567890, + * version: 8.15.0, + * buildHash: 'ffffffffaaaaaaaa', + * }, + * } + * + * @example + * { + * kind: 'organization', + * key: 1234, + * in_trial: true, + * } + * + * @example + * { + * key: 12345567890, + * version: 8.15.0, + * buildHash: 'ffffffffaaaaaaaa', + * } + * } + */ + kind?: 'multi' | 'organization' | 'project' | 'deployment'; +}; + +/** + * 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 + */ + setContext(contextToAppend: EvaluationContext): Promise; +} + +/** + * Setup contract of the Feature Flags Service + * @public + */ +export interface FeatureFlagsStart { + /** + * Registers an {@link Eventing['addHandler'] | event handler} to the specified event name. + * Useful when the consumer needs to react to flag changes. + * @public + */ + addHandler: Eventing['addHandler']; + + /** + * Appends new keys to the evaluation context. + * @param contextToAppend The additional keys that should be appended/modified in the evaluation context. + * @public + */ + setContext(contextToAppend: EvaluationContext): Promise; + + /** + * Evaluates a boolean flag + * @public + */ + getBooleanValue(flagName: string, fallbackValue: boolean): boolean; + + /** + * Evaluates a string flag + * @public + */ + getStringValue(flagName: string, fallbackValue: string): string; + + /** + * Evaluates a number flag + * @public + */ + getNumberValue(flagName: string, fallbackValue: number): number; +} 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 0000000000000..5c989599ec9ad --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} 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 31116e7060b6d..e4b673db13b53 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 @@ -12,6 +12,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 @@ -20,6 +21,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 7ac2b4e34f102..6aacedf3594d2 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 @@ -12,11 +12,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/src/core_setup.ts b/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts index fa276f375414b..4f1d6898323f5 100644 --- a/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-browser/src/core_setup.ts @@ -9,6 +9,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'; @@ -43,6 +44,8 @@ export interface CoreSetup Date: Wed, 17 Jul 2024 19:06:46 +0200 Subject: [PATCH 02/41] Cloud Experiments integration + server service + mocks --- package.json | 5 + .../README.md | 4 +- .../src/feature_flags_service.ts | 42 ++++-- .../README.md | 3 + .../core-feature-flags-browser-mocks/index.ts | 42 ++++++ .../jest.config.js | 13 ++ .../kibana.jsonc | 5 + .../package.json | 6 + .../tsconfig.json | 19 +++ .../core-feature-flags-browser/index.ts | 1 + .../core-feature-flags-browser/src/types.ts | 22 +-- .../README.md | 5 + .../index.ts | 9 ++ .../jest.config.js | 13 ++ .../kibana.jsonc | 5 + .../package.json | 6 + .../src/feature_flags_service.ts | 97 +++++++++++++ .../tsconfig.json | 17 +++ .../core-feature-flags-server-mocks/README.md | 3 + .../core-feature-flags-server-mocks/index.ts | 42 ++++++ .../jest.config.js | 13 ++ .../kibana.jsonc | 5 + .../package.json | 6 + .../tsconfig.json | 17 +++ .../core-feature-flags-server/README.md | 3 + .../core-feature-flags-server/index.ts | 10 ++ .../core-feature-flags-server/jest.config.js | 13 ++ .../core-feature-flags-server/kibana.jsonc | 5 + .../core-feature-flags-server/package.json | 6 + .../core-feature-flags-server/src/types.ts | 120 ++++++++++++++++ .../core-feature-flags-server/tsconfig.json | 17 +++ .../src/core_setup.mock.ts | 2 + .../src/core_start.mock.ts | 2 + .../src/internal_core_setup.ts | 2 + .../src/internal_core_start.ts | 2 + .../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/src/core_setup.ts | 3 + .../core-lifecycle-server/src/core_start.ts | 3 + .../src/plugin_context.ts | 11 ++ .../core-root-server-internal/src/server.ts | 10 ++ src/core/public/mocks.ts | 1 + src/core/server/index.ts | 6 + src/core/server/mocks.ts | 1 + tsconfig.base.json | 8 ++ .../common/metadata_service/index.ts | 1 + .../metadata_service/initialize_metadata.ts | 64 +++++++++ .../metadata_service/metadata_service.ts | 129 +++++++++++++++--- .../metadata_service/remove_undefined.ts | 16 +++ .../cloud_experiments/kibana.jsonc | 4 +- .../launch_darkly_client.ts | 16 +-- .../cloud_experiments/public/plugin.ts | 100 ++++++++++---- .../launch_darkly_client.ts | 22 ++- .../cloud_experiments/server/plugin.ts | 64 +++++---- yarn.lock | 41 ++++-- 57 files changed, 954 insertions(+), 136 deletions(-) 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-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_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/jest.config.js 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/types.ts create mode 100644 packages/core/feature-flags/core-feature-flags-server/tsconfig.json 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 diff --git a/package.json b/package.json index 62d3808f73213..25a5ec4974852 100644 --- a/package.json +++ b/package.json @@ -278,6 +278,10 @@ "@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", @@ -964,6 +968,7 @@ "@mapbox/vector-tile": "1.3.1", "@mswjs/http-middleware": "^0.10.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", 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 index 33221f2df6286..f5696d4530483 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-internal/README.md +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/README.md @@ -1,3 +1,5 @@ # @kbn/core-feature-flags-browser-internal -Empty package generated by @kbn/generate +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/src/feature_flags_service.ts b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.ts index 58da623a4da33..5841c613e4023 100644 --- 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 @@ -15,16 +15,16 @@ import type { import type { Logger } from '@kbn/logging'; import { apm } from '@elastic/apm-rum'; import { type Client, OpenFeature } from '@openfeature/web-sdk'; +import deepMerge from 'deepmerge'; export class FeatureFlagsService { private readonly featureFlagsClient: Client; - private readonly isServerless: boolean; private readonly logger: Logger; private isProviderReadyPromise?: Promise; + private context: EvaluationContext = { kind: 'multi' }; constructor(core: CoreContext) { this.logger = core.logger.get('feature-flags-service'); - this.isServerless = core.env.packageInfo.buildFlavor === 'serverless'; this.featureFlagsClient = OpenFeature.getClient(); OpenFeature.setLogger(this.logger.get('open-feature')); } @@ -37,7 +37,7 @@ export class FeatureFlagsService { setProvider: (provider) => { this.isProviderReadyPromise = OpenFeature.setProviderAndWait(provider); }, - setContext: (contextToAppend) => this.setContext(contextToAppend), + appendContext: (contextToAppend) => this.appendContext(contextToAppend), }; } @@ -45,17 +45,11 @@ export class FeatureFlagsService { * Start lifecycle method */ public async start(): Promise { - // Adding a timeout here to avoid hanging the start for too long if the provider is unresponsive - await Promise.race([ - this.isProviderReadyPromise, - await new Promise((resolve) => setTimeout(resolve, 2 * 1000)).then(() => { - this.logger.warn('The feature flags provider took too long to initialize'); - }), - ]); + await this.waitForProviderInitialization(); return { addHandler: this.featureFlagsClient.addHandler.bind(this.featureFlagsClient), - setContext: (contextToAppend) => this.setContext(contextToAppend), + appendContext: (contextToAppend) => this.appendContext(contextToAppend), getBooleanValue: (flagName: string, fallbackValue: boolean) => { // TODO: intercept with config overrides const value = this.featureFlagsClient.getBooleanValue(flagName, fallbackValue); @@ -87,16 +81,34 @@ export class FeatureFlagsService { await OpenFeature.close(); } + 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, + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 2 * 1000); + }).then(() => { + this.logger.warn('The feature flags provider took too long to initialize'); + }), + ]); + clearTimeout(timeoutId); + } + /** * Formats the provided context to fulfill the expected multi-context structure. * @param contextToAppend The {@link EvaluationContext} to append. * @private */ - private async setContext(contextToAppend: EvaluationContext): Promise { + private async appendContext(contextToAppend: EvaluationContext): Promise { // If no kind provided, default to the project|deployment level. - const { kind = this.isServerless ? 'project' : 'deployment', ...rest } = contextToAppend; + const { kind = 'kibana', ...rest } = contextToAppend; // Format the context to fulfill the expected multi-context structure - const context = kind !== 'multi' ? { kind: 'multi', [kind]: rest } : contextToAppend; - await OpenFeature.setContext(context); + const formattedContextToAppend: EvaluationContext = + kind !== 'multi' ? { kind: 'multi', [kind]: rest } : contextToAppend; + + // 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-mocks/README.md b/packages/core/feature-flags/core-feature-flags-browser-mocks/README.md new file mode 100644 index 0000000000000..db756eddf2f1a --- /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 0000000000000..bd2587e4348a4 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/index.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 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 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'; + +const createFeatureFlagsSetup = (): jest.Mocked => { + return { + setProvider: jest.fn(), + appendContext: jest.fn().mockImplementation(Promise.resolve), + }; +}; + +const createFeatureFlagsStart = (): jest.Mocked => { + return { + addHandler: jest.fn(), + appendContext: jest.fn().mockImplementation(Promise.resolve), + getBooleanValue: jest.fn(), + getNumberValue: jest.fn(), + getStringValue: jest.fn(), + }; +}; + +const createFeatureFlagsServiceMock = (): jest.Mocked> => { + return { + setup: jest.fn().mockImplementation(createFeatureFlagsSetup), + start: jest.fn().mockImplementation(async () => createFeatureFlagsStart()), + stop: jest.fn().mockImplementation(Promise.resolve), + }; +}; + +export const coreFeatureFlagsMock = { + create: createFeatureFlagsServiceMock, + createSetup: createFeatureFlagsSetup, + 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 0000000000000..f715bab35774c --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 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 0000000000000..9ce23dd0802e8 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "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 0000000000000..54462db563661 --- /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": "SSPL-1.0 OR Elastic License 2.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 0000000000000..5c989599ec9ad --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/packages/core/feature-flags/core-feature-flags-browser/index.ts b/packages/core/feature-flags/core-feature-flags-browser/index.ts index 450e26b5ad1f7..466427e7a3da7 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/index.ts +++ b/packages/core/feature-flags/core-feature-flags-browser/index.ts @@ -6,4 +6,5 @@ * Side Public License, v 1. */ +export { ClientProviderEvents } from './src/types'; export type { EvaluationContext, FeatureFlagsSetup, FeatureFlagsStart } from './src/types'; 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 index c24fafbc8fb24..e2d4b1f2aedf1 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/src/types.ts +++ b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts @@ -13,39 +13,43 @@ import { type Eventing, } from '@openfeature/core'; +export { ClientProviderEvents }; + /** * The evaluation context to use when retrieving the flags. * @public */ export type EvaluationContext = OpenFeatureEvaluationContext & { /** - * We use multi-context so that we can apply segmentation rules at different levels (organization/project/deployment). + * We use multi-context so that we can apply segmentation rules at different levels (`organization`/`kibana`). + * * `organization` includes any information that it's 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 `project`|`deployment`, depending on the offering (Serverless vs. Traditional). + * If no `kind` is provided, it defaults to `kibana`. * @public * - * @example + * @example Providing properties for both contexts * { * kind: 'multi', * organization: { * key: 1234, * in_trial: true, * }, - * project|deployment: { + * kibana: { * key: 12345567890, * version: 8.15.0, * buildHash: 'ffffffffaaaaaaaa', * }, * } * - * @example + * @example Appending context to the organization sub-context * { * kind: 'organization', * key: 1234, * in_trial: true, * } * - * @example + * @example Appending context to the `kibana` sub-context * { * key: 12345567890, * version: 8.15.0, @@ -53,7 +57,7 @@ export type EvaluationContext = OpenFeatureEvaluationContext & { * } * } */ - kind?: 'multi' | 'organization' | 'project' | 'deployment'; + kind?: 'multi' | 'organization' | 'kibana'; }; /** @@ -74,7 +78,7 @@ export interface FeatureFlagsSetup { * @param contextToAppend The additional keys that should be appended/modified in the evaluation context. * @public */ - setContext(contextToAppend: EvaluationContext): Promise; + appendContext(contextToAppend: EvaluationContext): Promise; } /** @@ -94,7 +98,7 @@ export interface FeatureFlagsStart { * @param contextToAppend The additional keys that should be appended/modified in the evaluation context. * @public */ - setContext(contextToAppend: EvaluationContext): Promise; + appendContext(contextToAppend: EvaluationContext): Promise; /** * Evaluates a boolean flag 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 0000000000000..288d47fdc95eb --- /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 0000000000000..b2cdfd473fc5f --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/index.ts @@ -0,0 +1,9 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +export { FeatureFlagsService } from './src/feature_flags_service'; 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 0000000000000..6b0fd80dec92d --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 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 0000000000000..21d7e6688cf9a --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "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 0000000000000..b226626cf97c2 --- /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": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file 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 0000000000000..06564bce0f475 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts @@ -0,0 +1,97 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import type { CoreContext } from '@kbn/core-base-server-internal'; +import type { + EvaluationContext, + FeatureFlagsSetup, + FeatureFlagsStart, +} from '@kbn/core-feature-flags-server'; +import type { Logger } from '@kbn/logging'; +import apm from 'elastic-apm-node'; +import { type Client, OpenFeature } from '@openfeature/server-sdk'; +import deepMerge from 'deepmerge'; + +export class FeatureFlagsService { + private readonly featureFlagsClient: Client; + private readonly logger: Logger; + private context: EvaluationContext = { kind: 'multi' }; + + 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 + */ + public setup(): FeatureFlagsSetup { + return { + setProvider: (provider) => { + OpenFeature.setProvider(provider); + }, + appendContext: (contextToAppend) => this.appendContext(contextToAppend), + }; + } + + /** + * Start lifecycle method + */ + public start(): FeatureFlagsStart { + return { + addHandler: this.featureFlagsClient.addHandler.bind(this.featureFlagsClient), + appendContext: (contextToAppend) => this.appendContext(contextToAppend), + getBooleanValue: async (flagName: string, fallbackValue: boolean) => { + // TODO: intercept with config overrides + const value = await this.featureFlagsClient.getBooleanValue(flagName, fallbackValue); + apm.addLabels({ [`flag_${flagName}`]: value }); + // TODO: increment usage counter + return value; + }, + getStringValue: async (flagName: string, fallbackValue: string) => { + // TODO: intercept with config overrides + const value = await this.featureFlagsClient.getStringValue(flagName, fallbackValue); + apm.addLabels({ [`flag_${flagName}`]: value }); + // TODO: increment usage counter + return value; + }, + getNumberValue: async (flagName: string, fallbackValue: number) => { + // TODO: intercept with config overrides + const value = await this.featureFlagsClient.getNumberValue(flagName, fallbackValue); + apm.addLabels({ [`flag_${flagName}`]: value }); + // TODO: increment usage counter + return value; + }, + }; + } + + /** + * Stop lifecycle method + */ + public async stop() { + await OpenFeature.close(); + } + + /** + * 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: EvaluationContext = + kind !== 'multi' ? { kind: 'multi', [kind]: rest } : contextToAppend; + + // 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 0000000000000..0d78dace105e1 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} 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 0000000000000..caf2c4a13f8fb --- /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 0000000000000..80b170dc2c73c --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/index.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 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 or the Server + * Side Public License, v 1. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { FeatureFlagsSetup, FeatureFlagsStart } from '@kbn/core-feature-flags-server'; +import type { FeatureFlagsService } from '@kbn/core-feature-flags-server-internal'; + +const createFeatureFlagsSetup = (): jest.Mocked => { + return { + setProvider: jest.fn(), + appendContext: jest.fn(), + }; +}; + +const createFeatureFlagsStart = (): jest.Mocked => { + return { + addHandler: jest.fn(), + appendContext: jest.fn(), + 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(createFeatureFlagsSetup), + start: jest.fn().mockImplementation(createFeatureFlagsStart), + stop: jest.fn().mockImplementation(Promise.resolve), + }; +}; + +export const coreFeatureFlagsMock = { + create: createFeatureFlagsServiceMock, + createSetup: createFeatureFlagsSetup, + createStart: createFeatureFlagsStart, +}; 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 0000000000000..5c7b537679453 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 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 0000000000000..e7f8905510705 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "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 0000000000000..8e5e9deea30b3 --- /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": "SSPL-1.0 OR Elastic License 2.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 0000000000000..0d78dace105e1 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} 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 0000000000000..86b6fc210d0d4 --- /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 0000000000000..5f165216ebfb6 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/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 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 or the Server + * Side Public License, v 1. + */ + +export { ServerProviderEvents } from './src/types'; +export type { EvaluationContext, FeatureFlagsSetup, FeatureFlagsStart } from './src/types'; diff --git a/packages/core/feature-flags/core-feature-flags-server/jest.config.js b/packages/core/feature-flags/core-feature-flags-server/jest.config.js new file mode 100644 index 0000000000000..691e5140f198b --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 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'], +}; 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 0000000000000..9b27818c61bcc --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "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 0000000000000..1062176d837c5 --- /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": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/core/feature-flags/core-feature-flags-server/src/types.ts b/packages/core/feature-flags/core-feature-flags-server/src/types.ts new file mode 100644 index 0000000000000..8bf9b89f64e5f --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/src/types.ts @@ -0,0 +1,120 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import type { Provider } from '@openfeature/server-sdk'; +import { + ServerProviderEvents, + type EvaluationContext as OpenFeatureEvaluationContext, + type Eventing, +} from '@openfeature/core'; + +export { ServerProviderEvents }; + +/** + * The evaluation context to use when retrieving the flags. + * @public + */ +export type EvaluationContext = OpenFeatureEvaluationContext & { + /** + * We use multi-context so that we can apply segmentation rules at different levels (`organization`/`kibana`). + * * `organization` includes any information that it's 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`. + * @public + * + * @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', + * } + * } + */ + kind?: 'multi' | '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 { + /** + * Registers an {@link Eventing['addHandler'] | event handler} to the specified event name. + * Useful when the consumer needs to react to flag changes. + * @public + */ + addHandler: Eventing['addHandler']; + + /** + * 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 + * @public + */ + getBooleanValue(flagName: string, fallbackValue: boolean): Promise; + + /** + * Evaluates a string flag + * @public + */ + getStringValue(flagName: string, fallbackValue: string): Promise; + + /** + * Evaluates a number flag + * @public + */ + getNumberValue(flagName: string, fallbackValue: number): Promise; +} 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 0000000000000..0d78dace105e1 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} 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 1ce30deb63782..d9839519d8cb9 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 @@ -20,6 +20,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 = '', @@ -37,6 +38,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 b33b22c12f9c8..9f44e01d05cfe 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 @@ -23,6 +23,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 = { @@ -32,6 +33,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-server-internal/src/internal_core_setup.ts b/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_setup.ts index 43a8287e51919..ff6d7a008d012 100644 --- a/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_setup.ts @@ -28,6 +28,7 @@ import type { InternalCustomBrandingSetup } from '@kbn/core-custom-branding-serv import type { InternalUserSettingsServiceSetup } from '@kbn/core-user-settings-server-internal'; import type { InternalSecurityServiceSetup } from '@kbn/core-security-server-internal'; import type { InternalUserProfileServiceSetup } from '@kbn/core-user-profile-server-internal'; +import type { FeatureFlagsSetup } from '@kbn/core-feature-flags-server'; /** @internal */ export interface InternalCoreSetup { @@ -38,6 +39,7 @@ export interface InternalCoreSetup { http: InternalHttpServiceSetup; elasticsearch: InternalElasticsearchServiceSetup; executionContext: InternalExecutionContextSetup; + featureFlags: FeatureFlagsSetup; i18n: I18nServiceSetup; savedObjects: InternalSavedObjectsServiceSetup; status: InternalStatusServiceSetup; diff --git a/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_start.ts b/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_start.ts index b2c9da09003d2..8d6014111081a 100644 --- a/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_start.ts +++ b/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_start.ts @@ -12,6 +12,7 @@ import type { InternalDeprecationsServiceStart } from '@kbn/core-deprecations-se import type { DocLinksServiceStart } from '@kbn/core-doc-links-server'; import type { InternalElasticsearchServiceStart } from '@kbn/core-elasticsearch-server-internal'; import type { InternalExecutionContextStart } from '@kbn/core-execution-context-server-internal'; +import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server'; import type { InternalHttpServiceStart } from '@kbn/core-http-server-internal'; import type { InternalMetricsServiceStart } from '@kbn/core-metrics-server-internal'; import type { InternalSavedObjectsServiceStart } from '@kbn/core-saved-objects-server-internal'; @@ -28,6 +29,7 @@ export interface InternalCoreStart { analytics: AnalyticsServiceStart; capabilities: CapabilitiesStart; elasticsearch: InternalElasticsearchServiceStart; + featureFlags: FeatureFlagsStart; docLinks: DocLinksServiceStart; http: InternalHttpServiceStart; metrics: InternalMetricsServiceStart; diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_setup.mock.ts b/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_setup.mock.ts index 4f0f8cd7a66dd..a16a7b1940775 100644 --- a/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_setup.mock.ts +++ b/packages/core/lifecycle/core-lifecycle-server-mocks/src/core_setup.mock.ts @@ -29,6 +29,7 @@ import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks'; import { securityServiceMock } from '@kbn/core-security-server-mocks'; import { userProfileServiceMock } from '@kbn/core-user-profile-server-mocks'; import { createCoreStartMock } from './core_start.mock'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; type CoreSetupMockType = MockedKeys & { elasticsearch: ReturnType; @@ -60,6 +61,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 1ca7b1a096e39..a8e8c5e1794ce 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 @@ -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 createCoreStartMock() { const mock: MockedKeys = { @@ -28,6 +29,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 fe998ddf721b1..316440aa92098 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 @@ -28,6 +28,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 = { @@ -36,6 +37,7 @@ export function createInternalCoreSetupMock() { context: contextServiceMock.createSetupContract(), docLinks: docLinksServiceMock.createSetupContract(), elasticsearch: elasticsearchServiceMock.createInternalSetup(), + featureFlags: coreFeatureFlagsMock.createSetup(), 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 5230ce25a0ead..dcfa062ce166d 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 @@ -20,6 +20,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 = { @@ -27,6 +28,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/src/core_setup.ts b/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts index c590d562a433c..d62335cda7109 100644 --- a/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-server/src/core_setup.ts @@ -12,6 +12,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'; @@ -51,6 +52,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 96564d733c672..5c912af25e134 100644 --- a/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts +++ b/packages/core/lifecycle/core-lifecycle-server/src/core_start.ts @@ -11,6 +11,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'; @@ -39,6 +40,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/plugins/core-plugins-server-internal/src/plugin_context.ts b/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts index 70551c1e27504..049ed57598a83 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 @@ -217,6 +217,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: < @@ -331,6 +335,13 @@ export function createPluginStartContext({ getCapabilities: deps.elasticsearch.getCapabilities, }, executionContext: deps.executionContext, + featureFlags: { + addHandler: deps.featureFlags.addHandler, + appendContext: deps.featureFlags.appendContext, + 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/root/core-root-server-internal/src/server.ts b/packages/core/root/core-root-server-internal/src/server.ts index d380bffd8b359..dc167b42a14ea 100644 --- a/packages/core/root/core-root-server-internal/src/server.ts +++ b/packages/core/root/core-root-server-internal/src/server.ts @@ -19,6 +19,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'; @@ -68,6 +69,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; @@ -117,6 +119,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); @@ -340,6 +343,8 @@ export class Server { rendering: renderingSetup, }); + const featureFlagsSetup = this.featureFlags.setup(); + const loggingSetup = this.logging.setup(); const coreSetup: InternalCoreSetup = { @@ -351,6 +356,7 @@ export class Server { elasticsearch: elasticsearchServiceSetup, environment: environmentSetup, executionContext: executionContextSetup, + featureFlags: featureFlagsSetup, http: httpSetup, i18n: i18nServiceSetup, savedObjects: savedObjectsSetup, @@ -431,6 +437,8 @@ export class Server { exposedConfigsToUsage: this.plugins.getExposedPluginConfigsToUsage(), }); + const featureFlagsStart = this.featureFlags.start(); + this.status.start(); this.coreStart = { @@ -440,6 +448,7 @@ export class Server { docLinks: docLinkStart, elasticsearch: elasticsearchStart, executionContext: executionContextStart, + featureFlags: featureFlagsStart, http: httpStart, metrics: metricsStart, savedObjects: savedObjectsStart, @@ -480,6 +489,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/src/core/public/mocks.ts b/src/core/public/mocks.ts index bc45122b68b48..2acecf8e97f0b 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -20,6 +20,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 1e89c9ed5b24e..19275349a1dd9 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -70,6 +70,12 @@ 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 { ServerProviderEvents } from '@kbn/core-feature-flags-server'; export type { Capabilities } from '@kbn/core-capabilities-common'; export type { CapabilitiesProvider, diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 13321fb92367b..d33676292cdfa 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -45,6 +45,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'; diff --git a/tsconfig.base.json b/tsconfig.base.json index cbcc3e9df44d9..ed4167ae23781 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -346,6 +346,14 @@ "@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"], 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 74e2655e8302f..b62a96ea3613f 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 0000000000000..8a9c143fc964e --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/initialize_metadata.ts @@ -0,0 +1,64 @@ +/* + * 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 { 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; + cloud: CloudSetupBrowser | CloudSetupServer; + logger: Logger; +}) { + const offering = initializerContext.env.packageInfo.buildFlavor; + + const orgId = 'FAKE_ID'; // TODO: Retrieve it when available + + 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: 'canary', // TODO: Retrieve this + organizationKey: orgId, + 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 on when the metadataService stops because it completes the Observable +} 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 ddb2bc86d7dca..06c28a16c8032 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 0000000000000..437335a0f4096 --- /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/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_experiments/kibana.jsonc index 743bf70001dd6..3c6b9f8279f01 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/launch_darkly_client.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts index bc2064ec6bcf0..554289e1f2b0c 100644 --- 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 @@ -7,8 +7,8 @@ import { type LDClient, - type LDSingleKindContext, type LDLogLevel, + type LDMultiKindContext, } from 'launchdarkly-js-client-sdk'; import { BehaviorSubject, filter, firstValueFrom, switchMap } from 'rxjs'; import type { Logger } from '@kbn/logging'; @@ -23,6 +23,7 @@ export interface LaunchDarklyUserMetadata userId: string; } +// TODO: Legacy client. Remove when the migration is complete export class LaunchDarklyClient { private initialized = false; private canceled = false; @@ -41,27 +42,20 @@ export class LaunchDarklyClient { private readonly logger: Logger ) {} - public async updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) { + public async updateUserMetadata(userMetadata: LDMultiKindContext): Promise { 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); + await launchDarklyClient.identify(userMetadata); } else { this.initialized = true; const { initialize, basicLogger } = await import('launchdarkly-js-client-sdk'); - launchDarklyClient = initialize(this.ldConfig.client_id, launchDarklyUser, { + launchDarklyClient = initialize(this.ldConfig.client_id, userMetadata, { application: { id: 'kibana-browser', version: this.kibanaVersion }, logger: basicLogger({ level: this.ldConfig.client_log_level }), }); 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 a201c98df1ea3..c0c5e6d99807c 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts @@ -9,17 +9,20 @@ import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kb import { get, has } from 'lodash'; import { duration } from 'moment'; import { concatMap } from 'rxjs'; -import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; +import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { Logger } from '@kbn/logging'; +import { LaunchDarklyClientProvider } from '@openfeature/launchdarkly-client-provider'; +import { ClientProviderEvents } from '@openfeature/core'; +import type { LDMultiKindContext } from 'launchdarkly-js-client-sdk'; import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client'; import type { CloudExperimentsFeatureFlagNames, CloudExperimentsMetric, CloudExperimentsPluginStart, } from '../common'; -import { MetadataService } from '../common/metadata_service'; +import { initializeMetadata, MetadataService } from '../common/metadata_service'; import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants'; interface CloudExperimentsPluginSetupDeps { @@ -27,7 +30,6 @@ interface CloudExperimentsPluginSetupDeps { } interface CloudExperimentsPluginStartDeps { - cloud: CloudStart; dataViews: DataViewsPublicPluginStart; } @@ -40,15 +42,13 @@ export class CloudExperimentsPlugin 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; @@ -72,7 +72,12 @@ export class CloudExperimentsPlugin ); } if (ldConfig?.client_id) { - this.launchDarklyClient = new LaunchDarklyClient(ldConfig, this.kibanaVersion, this.logger); + // Disabled to make it easier for manual tests of the new client + // this.launchDarklyClient = new LaunchDarklyClient( + // ldConfig, + // this.initializerContext.env.packageInfo.version, + // this.logger + // ); } } @@ -82,13 +87,30 @@ export class CloudExperimentsPlugin * @param deps {@link CloudExperimentsPluginSetupDeps} */ public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) { + initializeMetadata({ + metadataService: this.metadataService, + initializerContext: this.initializerContext, + cloud: deps.cloud, + featureFlags: core.featureFlags, + logger: this.logger, + }); + core.featureFlags.setProvider(this.createOpenFeatureProvider()); + + // TODO: Legacy client. Remove when the migration is complete 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, - }); + // Update the client's contexts when we get any updates in the metadata. + this.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 this.launchDarklyClient?.updateUserMetadata(userMetadata as LDMultiKindContext); + } catch (err) { + this.logger.warn(`Failed to set the context in the legacy client ${err}`); + } + }) + ) + .subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable } else { this.launchDarklyClient?.cancel(); } @@ -100,24 +122,26 @@ export class CloudExperimentsPlugin */ public start( core: CoreStart, - { cloud, dataViews }: CloudExperimentsPluginStartDeps + { dataViews }: CloudExperimentsPluginStartDeps ): CloudExperimentsPluginStart { - if (cloud.isCloudEnabled) { - this.metadataService.start({ - hasDataFetcher: async () => ({ hasData: await dataViews.hasData.hasUserDataView() }), - }); + this.logger.info( + `The value is ${core.featureFlags.getStringValue('building-materials', 'fallback')}` + ); + + core.featureFlags.addHandler(ClientProviderEvents.ConfigurationChanged, (event) => { + this.logger.info(`[Configuration changed] Flags changed! ${event?.flagsChanged}`); + this.logger.info( + `[Configuration changed] The value now is ${core.featureFlags.getStringValue( + 'building-materials', + 'fallback' + )}` + ); + }); + + this.metadataService.start({ + hasDataFetcher: async () => ({ has_data: 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, @@ -161,4 +185,22 @@ export class CloudExperimentsPlugin }); } }; + + private createOpenFeatureProvider() { + const { launch_darkly: ldConfig } = this.initializerContext.config.get<{ + launch_darkly: LaunchDarklyClientConfig; + }>(); + + return new LaunchDarklyClientProvider(ldConfig.client_id, { + logger: this.logger.get('launch-darkly'), + 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/launch_darkly_client/launch_darkly_client.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts index c6511302eb7b1..e47c842c139fb 100644 --- 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 @@ -5,11 +5,11 @@ * 2.0. */ -import { - type LDClient, - type LDFlagSet, - type LDLogLevel, - type LDSingleKindContext, +import type { + LDClient, + LDFlagSet, + LDLogLevel, + LDMultiKindContext, } from '@launchdarkly/node-server-sdk'; import { init, basicLogger } from '@launchdarkly/node-server-sdk'; import type { Logger } from '@kbn/core/server'; @@ -40,9 +40,10 @@ export interface LaunchDarklyGetAllFlags { flagNames: string[]; } +// TODO: Legacy client. Remove when the migration is complete export class LaunchDarklyClient { private readonly launchDarklyClient: LDClient; - private launchDarklyUser?: LDSingleKindContext; + private launchDarklyUser?: LDMultiKindContext; constructor(ldConfig: LaunchDarklyClientConfig, private readonly logger: Logger) { this.launchDarklyClient = init(ldConfig.sdk_key, { @@ -59,13 +60,8 @@ export class LaunchDarklyClient { ); } - public updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) { - const { userId, ...userMetadataWithoutUserId } = userMetadata; - this.launchDarklyUser = { - ...userMetadataWithoutUserId, - kind: 'user', - key: userId, - }; + public updateUserMetadata(userMetadata: LDMultiKindContext) { + this.launchDarklyUser = userMetadata; } public async getVariation(configKey: string, defaultValue: Data): Promise { 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 834784a11f2c5..0069fde18ee1d 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts @@ -13,12 +13,13 @@ import type { Logger, } from '@kbn/core/server'; import { get, has } from 'lodash'; -import type { LogMeta } from '@kbn/logging'; +import { filter, map } from 'rxjs'; +import type { LDMultiKindContext } from 'launchdarkly-node-server-sdk'; +import type { LogLevelId, LogMeta } 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 { initializeMetadata, MetadataService } from '../common/metadata_service'; import { LaunchDarklyClient } from './launch_darkly_client'; import { registerUsageCollector } from './usage'; import type { CloudExperimentsConfigType } from './config'; @@ -31,7 +32,7 @@ import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants'; interface CloudExperimentsPluginSetupDeps { cloud: CloudSetup; - usageCollection?: UsageCollectionSetup; + usageCollection: UsageCollectionSetup; } interface CloudExperimentsPluginStartDeps { @@ -67,38 +68,47 @@ export class CloudExperimentsPlugin ); } if (ldConfig) { - this.launchDarklyClient = new LaunchDarklyClient( - { - ...ldConfig, - kibana_version: initializerContext.env.packageInfo.version, - }, - this.logger.get('launch_darkly') - ); + // 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, - })); - } + 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: [] }] }; + }) + ) + ); + registerUsageCollector(deps.usageCollection, () => ({ + launchDarklyClient: this.launchDarklyClient, // TODO: Return the client from the OpenFeature Provider + })); - 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, - }); + initializeMetadata({ + metadataService: this.metadataService, + initializerContext: this.initializerContext, + cloud: deps.cloud, + featureFlags: core.featureFlags, + logger: this.logger, + }); + // TODO: Legacy client. Remove when the migration is complete + if (deps.cloud.isCloudEnabled && deps.cloud.deploymentId) { // 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)) + map((userMetadata) => + this.launchDarklyClient?.updateUserMetadata(userMetadata as LDMultiKindContext) + ) ) .subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable } @@ -146,7 +156,7 @@ export class CloudExperimentsPlugin 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 +164,7 @@ export class CloudExperimentsPlugin true // Ignore capabilities checks ); return { - hasData: await dataViewsService.hasUserDataView(), + has_data: await dataViewsService.hasUserDataView(), }; } } diff --git a/yarn.lock b/yarn.lock index 675ae685e310e..a4122b011ab58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3945,6 +3945,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 "" @@ -5021,14 +5045,6 @@ 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@link:packages/core/feature-flags/core-feature-flags-browser": - 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 "" @@ -7818,6 +7834,13 @@ 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" @@ -22289,7 +22312,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= From cf8d28c02597a6894cbeb8453f21b45bb0988cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 17 Jul 2024 23:24:59 +0200 Subject: [PATCH 03/41] Actual usage --- package.json | 2 +- .../src/feature_flags_service.ts | 89 ++++++++++++++---- .../core-feature-flags-browser/index.ts | 1 - .../core-feature-flags-browser/src/types.ts | 38 ++++---- .../src/feature_flags_service.ts | 93 +++++++++++++++---- .../core-feature-flags-server/index.ts | 1 - .../core-feature-flags-server/src/types.ts | 38 ++++---- renovate.json | 7 +- src/plugins/home/kibana.jsonc | 3 +- src/plugins/navigation/kibana.jsonc | 2 +- src/plugins/navigation/public/plugin.tsx | 22 ++--- src/plugins/navigation/public/types.ts | 2 - .../cloud_chat/kibana.jsonc | 1 - .../cloud_chat/server/plugin.ts | 36 ++----- .../metadata_service/initialize_metadata.ts | 3 +- .../cloud_experiments/public/plugin.ts | 25 +++-- .../cloud_experiments/server/plugin.ts | 47 +++++++++- .../server/usage/register_usage_collector.ts | 23 ++++- .../observability_onboarding/kibana.jsonc | 3 +- .../observability_onboarding/public/plugin.ts | 2 - x-pack/plugins/spaces/kibana.jsonc | 3 +- .../experiments/is_solution_nav_enabled.ts | 13 +-- x-pack/plugins/spaces/public/plugin.tsx | 4 +- yarn.lock | 44 +++------ 24 files changed, 307 insertions(+), 195 deletions(-) diff --git a/package.json b/package.json index 25a5ec4974852..216a864250f44 100644 --- a/package.json +++ b/package.json @@ -958,6 +958,7 @@ "@langchain/openai": "^0.1.3", "@langtrase/trace-attributes": "^3.0.8", "@launchdarkly/node-server-sdk": "^9.4.7", + "@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", @@ -1103,7 +1104,6 @@ "langchain": "^0.2.10", "langsmith": "^0.1.37", "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/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 index 5841c613e4023..93973d8c372b3 100644 --- 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 @@ -14,8 +14,9 @@ import type { } from '@kbn/core-feature-flags-browser'; import type { Logger } from '@kbn/logging'; import { apm } from '@elastic/apm-rum'; -import { type Client, OpenFeature } from '@openfeature/web-sdk'; +import { type Client, ClientProviderEvents, OpenFeature } from '@openfeature/web-sdk'; import deepMerge from 'deepmerge'; +import { filter, map, startWith, Subject } from 'rxjs'; export class FeatureFlagsService { private readonly featureFlagsClient: Client; @@ -45,31 +46,56 @@ export class FeatureFlagsService { * 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([]) // only to emit on the first call + ); + await this.waitForProviderInitialization(); return { - addHandler: this.featureFlagsClient.addHandler.bind(this.featureFlagsClient), appendContext: (contextToAppend) => this.appendContext(contextToAppend), - getBooleanValue: (flagName: string, fallbackValue: boolean) => { - // TODO: intercept with config overrides - const value = this.featureFlagsClient.getBooleanValue(flagName, fallbackValue); - apm.addLabels({ [`flag_${flagName}`]: value }); - // TODO: increment usage counter - return value; + 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: string) => { - // TODO: intercept with config overrides - const value = this.featureFlagsClient.getStringValue(flagName, fallbackValue); - apm.addLabels({ [`flag_${flagName}`]: value }); - // TODO: increment usage counter - return value; + getStringValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + map(() => + this.evaluateFlag( + this.featureFlagsClient.getStringValue, + flagName, + fallbackValue + ) + ) + ); }, - getNumberValue: (flagName: string, fallbackValue: number) => { - // TODO: intercept with config overrides - const value = this.featureFlagsClient.getNumberValue(flagName, fallbackValue); - apm.addLabels({ [`flag_${flagName}`]: value }); - // TODO: increment usage counter - return value; + getNumberValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + map(() => + this.evaluateFlag( + this.featureFlagsClient.getNumberValue, + flagName, + fallbackValue + ) + ) + ); }, }; } @@ -81,6 +107,10 @@ export class FeatureFlagsService { 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; @@ -95,6 +125,25 @@ export class FeatureFlagsService { 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 { + // TODO: intercept with config overrides + const value = evaluationFn(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. diff --git a/packages/core/feature-flags/core-feature-flags-browser/index.ts b/packages/core/feature-flags/core-feature-flags-browser/index.ts index 466427e7a3da7..450e26b5ad1f7 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/index.ts +++ b/packages/core/feature-flags/core-feature-flags-browser/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export { ClientProviderEvents } from './src/types'; export type { EvaluationContext, FeatureFlagsSetup, FeatureFlagsStart } from './src/types'; 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 index e2d4b1f2aedf1..c7f8da0ae81d4 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/src/types.ts +++ b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts @@ -7,13 +7,8 @@ */ import type { Provider } from '@openfeature/web-sdk'; -import { - ClientProviderEvents, - type EvaluationContext as OpenFeatureEvaluationContext, - type Eventing, -} from '@openfeature/core'; - -export { ClientProviderEvents }; +import { type EvaluationContext as OpenFeatureEvaluationContext } from '@openfeature/core'; +import type { Observable } from 'rxjs'; /** * The evaluation context to use when retrieving the flags. @@ -86,13 +81,6 @@ export interface FeatureFlagsSetup { * @public */ export interface FeatureFlagsStart { - /** - * Registers an {@link Eventing['addHandler'] | event handler} to the specified event name. - * Useful when the consumer needs to react to flag changes. - * @public - */ - addHandler: Eventing['addHandler']; - /** * Appends new keys to the evaluation context. * @param contextToAppend The additional keys that should be appended/modified in the evaluation context. @@ -110,11 +98,29 @@ export interface FeatureFlagsStart { * Evaluates a string flag * @public */ - getStringValue(flagName: string, fallbackValue: string): string; + getStringValue(flagName: string, fallbackValue: Value): Value; /** * Evaluates a number flag * @public */ - getNumberValue(flagName: string, fallbackValue: number): number; + getNumberValue(flagName: string, fallbackValue: Value): Value; + + /** + * Returns an observable of a boolean flag + * @public + */ + getBooleanValue$(flagName: string, fallbackValue: boolean): Observable; + + /** + * Returns an observable of a string flag + * @public + */ + getStringValue$(flagName: string, fallbackValue: Value): Observable; + + /** + * Returns an observable of a number flag + * @public + */ + getNumberValue$(flagName: string, fallbackValue: Value): Observable; } 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 index 06564bce0f475..21a878fdcc235 100644 --- 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 @@ -14,8 +14,9 @@ import type { } from '@kbn/core-feature-flags-server'; import type { Logger } from '@kbn/logging'; import apm from 'elastic-apm-node'; -import { type Client, OpenFeature } from '@openfeature/server-sdk'; +import { type Client, OpenFeature, ServerProviderEvents } from '@openfeature/server-sdk'; import deepMerge from 'deepmerge'; +import { filter, mergeMap, startWith, Subject } from 'rxjs'; export class FeatureFlagsService { private readonly featureFlagsClient: Client; @@ -44,29 +45,62 @@ export class FeatureFlagsService { * 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([]) // only to emit on the first call + ); + return { - addHandler: this.featureFlagsClient.addHandler.bind(this.featureFlagsClient), appendContext: (contextToAppend) => this.appendContext(contextToAppend), - getBooleanValue: async (flagName: string, fallbackValue: boolean) => { - // TODO: intercept with config overrides - const value = await this.featureFlagsClient.getBooleanValue(flagName, fallbackValue); - apm.addLabels({ [`flag_${flagName}`]: value }); - // TODO: increment usage counter - return value; + 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( + mergeMap(() => + this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue) + ) + ); }, - getStringValue: async (flagName: string, fallbackValue: string) => { - // TODO: intercept with config overrides - const value = await this.featureFlagsClient.getStringValue(flagName, fallbackValue); - apm.addLabels({ [`flag_${flagName}`]: value }); - // TODO: increment usage counter - return value; + getStringValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + mergeMap(() => + this.evaluateFlag( + this.featureFlagsClient.getStringValue, + flagName, + fallbackValue + ) + ) + ); }, - getNumberValue: async (flagName: string, fallbackValue: number) => { - // TODO: intercept with config overrides - const value = await this.featureFlagsClient.getNumberValue(flagName, fallbackValue); - apm.addLabels({ [`flag_${flagName}`]: value }); - // TODO: increment usage counter - return value; + getNumberValue$: (flagName: string, fallbackValue: Value) => { + return observeFeatureFlag$(flagName).pipe( + mergeMap(() => + this.evaluateFlag( + this.featureFlagsClient.getNumberValue, + flagName, + fallbackValue + ) + ) + ); }, }; } @@ -78,6 +112,25 @@ export class FeatureFlagsService { 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 { + // TODO: intercept with config overrides + const value = await evaluationFn(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. diff --git a/packages/core/feature-flags/core-feature-flags-server/index.ts b/packages/core/feature-flags/core-feature-flags-server/index.ts index 5f165216ebfb6..450e26b5ad1f7 100644 --- a/packages/core/feature-flags/core-feature-flags-server/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export { ServerProviderEvents } from './src/types'; export type { EvaluationContext, FeatureFlagsSetup, FeatureFlagsStart } from './src/types'; diff --git a/packages/core/feature-flags/core-feature-flags-server/src/types.ts b/packages/core/feature-flags/core-feature-flags-server/src/types.ts index 8bf9b89f64e5f..c69bf0250337c 100644 --- a/packages/core/feature-flags/core-feature-flags-server/src/types.ts +++ b/packages/core/feature-flags/core-feature-flags-server/src/types.ts @@ -7,13 +7,8 @@ */ import type { Provider } from '@openfeature/server-sdk'; -import { - ServerProviderEvents, - type EvaluationContext as OpenFeatureEvaluationContext, - type Eventing, -} from '@openfeature/core'; - -export { ServerProviderEvents }; +import { type EvaluationContext as OpenFeatureEvaluationContext } from '@openfeature/core'; +import type { Observable } from 'rxjs'; /** * The evaluation context to use when retrieving the flags. @@ -86,13 +81,6 @@ export interface FeatureFlagsSetup { * @public */ export interface FeatureFlagsStart { - /** - * Registers an {@link Eventing['addHandler'] | event handler} to the specified event name. - * Useful when the consumer needs to react to flag changes. - * @public - */ - addHandler: Eventing['addHandler']; - /** * Appends new keys to the evaluation context. * @param contextToAppend The additional keys that should be appended/modified in the evaluation context. @@ -110,11 +98,29 @@ export interface FeatureFlagsStart { * Evaluates a string flag * @public */ - getStringValue(flagName: string, fallbackValue: string): Promise; + getStringValue(flagName: string, fallbackValue: Value): Promise; /** * Evaluates a number flag * @public */ - getNumberValue(flagName: string, fallbackValue: number): Promise; + getNumberValue(flagName: string, fallbackValue: Value): Promise; + + /** + * Returns an observable of a boolean flag + * @public + */ + getBooleanValue$(flagName: string, fallbackValue: boolean): Observable; + + /** + * Returns an observable of a string flag + * @public + */ + getStringValue$(flagName: string, fallbackValue: Value): Observable; + + /** + * Returns an observable of a number flag + * @public + */ + getNumberValue$(flagName: string, fallbackValue: Value): Observable; } diff --git a/renovate.json b/renovate.json index 5e8ad5058ec28..cb293fadccdf2 100644 --- a/renovate.json +++ b/renovate.json @@ -46,7 +46,12 @@ }, { "groupName": "LaunchDarkly", - "matchDepNames": ["launchdarkly-js-client-sdk", "@launchdarkly/node-server-sdk"], + "matchDepNames": [ + "launchdarkly-js-client-sdk", + "@openfeature/launchdarkly-client-provider", + "@launchdarkly/node-server-sdk", + "@launchdarkly/openfeature-node-server" + ], "reviewers": ["team:kibana-security", "team:kibana-core"], "matchBaseBranches": ["main"], "labels": ["release_note:skip", "Team:Security", "Team:Core", "backport:prev-minor"], diff --git a/src/plugins/home/kibana.jsonc b/src/plugins/home/kibana.jsonc index 33cb5c98e89db..8c0a7884ce8ee 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/src/plugins/navigation/kibana.jsonc b/src/plugins/navigation/kibana.jsonc index 96980c3c983f5..92cb0d492572d 100644 --- a/src/plugins/navigation/kibana.jsonc +++ b/src/plugins/navigation/kibana.jsonc @@ -6,7 +6,7 @@ "id": "navigation", "server": true, "browser": true, - "optionalPlugins": ["cloud", "cloudExperiments", "spaces"], + "optionalPlugins": ["cloud", "spaces"], "requiredPlugins": ["unifiedSearch"], "requiredBundles": [] } diff --git a/src/plugins/navigation/public/plugin.tsx b/src/plugins/navigation/public/plugin.tsx index bef9b7c3a933c..a56070deca9fc 100644 --- a/src/plugins/navigation/public/plugin.tsx +++ b/src/plugins/navigation/public/plugin.tsx @@ -6,16 +6,7 @@ * Side Public License, v 1. */ import React from 'react'; -import { - firstValueFrom, - from, - of, - ReplaySubject, - shareReplay, - take, - combineLatest, - map, -} from 'rxjs'; +import { firstValueFrom, of, ReplaySubject, take, combineLatest, map } from 'rxjs'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { Space } from '@kbn/spaces-plugin/public'; @@ -67,7 +58,7 @@ export class NavigationPublicPlugin this.coreStart = core; this.depsStart = depsStart; - const { unifiedSearch, cloud, cloudExperiments, spaces } = depsStart; + const { unifiedSearch, cloud, spaces } = depsStart; const extensions = this.topNavMenuExtensionsRegistry.getAll(); const chrome = core.chrome as InternalChromeStart; const activeSpace$ = spaces?.getActiveSpace$() ?? of(undefined); @@ -95,10 +86,11 @@ export class NavigationPublicPlugin const onCloud = cloud !== undefined; // The new side nav will initially only be available to cloud users const isServerless = this.initializerContext.env.packageInfo.buildFlavor === 'serverless'; - if (cloudExperiments && onCloud && !isServerless) { - this.isSolutionNavExperiementEnabled$ = from( - cloudExperiments.getVariation(SOLUTION_NAV_FEATURE_FLAG_NAME, false).catch(() => false) - ).pipe(shareReplay(1)); + if (onCloud && !isServerless) { + this.isSolutionNavExperiementEnabled$ = core.featureFlags.getBooleanValue$( + SOLUTION_NAV_FEATURE_FLAG_NAME, + false + ); } // Initialize the solution navigation if it is enabled diff --git a/src/plugins/navigation/public/types.ts b/src/plugins/navigation/public/types.ts index 4ce2efdfef84f..f5a4522d18233 100644 --- a/src/plugins/navigation/public/types.ts +++ b/src/plugins/navigation/public/types.ts @@ -12,7 +12,6 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import type { SolutionNavigationDefinition } from '@kbn/core-chrome-browser'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/public'; -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import { PanelContentProvider } from '@kbn/shared-ux-chrome-navigation'; import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup, createTopNav } from './top_nav_menu'; @@ -53,7 +52,6 @@ export interface NavigationPublicSetupDependencies { export interface NavigationPublicStartDependencies { unifiedSearch: UnifiedSearchPublicPluginStart; cloud?: CloudStart; - cloudExperiments?: CloudExperimentsPluginStart; spaces?: SpacesPluginStart; } diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc b/x-pack/plugins/cloud_integrations/cloud_chat/kibana.jsonc index 293d5f0baf3d7..6394ccc7b53f1 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 a708dd81cf532..242df76e789a8 100755 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts @@ -8,7 +8,6 @@ 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'; @@ -17,11 +16,7 @@ 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 +25,7 @@ export class CloudChatPlugin implements Plugin, { cloud }: CloudChatSetupDeps) { + public setup(core: CoreSetup, { cloud }: CloudChatSetupDeps) { const { chatIdentitySecret, trialBuffer } = this.config; const { isCloudEnabled, trialEndDate } = cloud; @@ -42,26 +37,15 @@ export class CloudChatPlugin implements Plugin - core.getStartServices().then(([_, { cloudExperiments }]) => { - if (!cloudExperiments) { - return 'header'; - } else { - return cloudExperiments - .getVariation('cloud-chat.chat-variant', 'header') - .catch(() => 'header'); - } - }), + core + .getStartServices() + .then(([{ featureFlags }]) => + featureFlags.getStringValue('cloud-chat.chat-variant', 'header') + ), getChatDisabledThroughExperiments: () => - core.getStartServices().then(([_, { cloudExperiments }]) => { - if (!cloudExperiments) { - return false; - } else { - return cloudExperiments - .getVariation('cloud-chat.enabled', true) - .then((enabled) => !enabled) - .catch(() => false); - } - }), + core + .getStartServices() + .then(([{ featureFlags }]) => featureFlags.getBooleanValue('cloud-chat.enabled', true)), }); } } 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 index 8a9c143fc964e..f5384847c1a3f 100644 --- 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 @@ -11,6 +11,7 @@ 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'; @@ -26,7 +27,7 @@ export function initializeMetadata({ }: { metadataService: MetadataService; initializerContext: PluginInitializerContextBrowser | PluginInitializerContextServer; - featureFlags: FeatureFlagsSetupBrowser; + featureFlags: FeatureFlagsSetupBrowser | FeatureFlagsSetupServer; cloud: CloudSetupBrowser | CloudSetupServer; logger: Logger; }) { 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 c0c5e6d99807c..1abbc7b60a07b 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts @@ -14,7 +14,6 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { Logger } from '@kbn/logging'; import { LaunchDarklyClientProvider } from '@openfeature/launchdarkly-client-provider'; -import { ClientProviderEvents } from '@openfeature/core'; import type { LDMultiKindContext } from 'launchdarkly-js-client-sdk'; import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client'; import type { @@ -60,9 +59,11 @@ export class CloudExperimentsPlugin this.logger.get('metadata') ); + // TODO: Legacy client. Remove when the migration is complete 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,6 +72,8 @@ export class CloudExperimentsPlugin 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); } + + // TODO: Legacy client. Remove when the migration is complete if (ldConfig?.client_id) { // Disabled to make it easier for manual tests of the new client // this.launchDarklyClient = new LaunchDarklyClient( @@ -94,7 +97,11 @@ export class CloudExperimentsPlugin featureFlags: core.featureFlags, logger: this.logger, }); - core.featureFlags.setProvider(this.createOpenFeatureProvider()); + + const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); + if (launchDarklyOpenFeatureProvider) { + core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); + } // TODO: Legacy client. Remove when the migration is complete if (deps.cloud.isCloudEnabled && deps.cloud.deploymentId && this.launchDarklyClient) { @@ -128,16 +135,6 @@ export class CloudExperimentsPlugin `The value is ${core.featureFlags.getStringValue('building-materials', 'fallback')}` ); - core.featureFlags.addHandler(ClientProviderEvents.ConfigurationChanged, (event) => { - this.logger.info(`[Configuration changed] Flags changed! ${event?.flagsChanged}`); - this.logger.info( - `[Configuration changed] The value now is ${core.featureFlags.getStringValue( - 'building-materials', - 'fallback' - )}` - ); - }); - this.metadataService.start({ hasDataFetcher: async () => ({ has_data: await dataViews.hasData.hasUserDataView() }), }); @@ -188,9 +185,11 @@ export class CloudExperimentsPlugin private createOpenFeatureProvider() { const { launch_darkly: ldConfig } = this.initializerContext.config.get<{ - launch_darkly: LaunchDarklyClientConfig; + launch_darkly?: LaunchDarklyClientConfig; }>(); + if (!ldConfig) return; + return new LaunchDarklyClientProvider(ldConfig.client_id, { logger: this.logger.get('launch-darkly'), streaming: true, // Necessary to react to flag changes 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 0069fde18ee1d..509712b75b038 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts @@ -14,7 +14,9 @@ import type { } from '@kbn/core/server'; import { get, has } from 'lodash'; import { filter, map } from 'rxjs'; -import type { LDMultiKindContext } from 'launchdarkly-node-server-sdk'; +import { OpenFeature } from '@openfeature/server-sdk'; +import { LaunchDarklyProvider } from '@launchdarkly/openfeature-node-server'; +import type { LDMultiKindContext } from '@launchdarkly/node-server-sdk'; import type { LogLevelId, LogMeta } from '@kbn/logging'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; @@ -40,7 +42,13 @@ interface CloudExperimentsPluginStartDeps { } export class CloudExperimentsPlugin - implements Plugin + implements + Plugin< + void, + CloudExperimentsPluginStart, + CloudExperimentsPluginSetupDeps, + CloudExperimentsPluginStartDeps + > { private readonly logger: Logger; private readonly launchDarklyClient?: LaunchDarklyClient; @@ -56,6 +64,7 @@ export class CloudExperimentsPlugin this.logger.get('metadata') ); + // TODO: Legacy client. Remove when the migration is complete if (config.flag_overrides) { this.flagOverrides = config.flag_overrides; } @@ -67,6 +76,8 @@ export class CloudExperimentsPlugin 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); } + + // TODO: Legacy client. Remove when the migration is complete if (ldConfig) { // this.launchDarklyClient = new LaunchDarklyClient( // { @@ -79,6 +90,7 @@ export class CloudExperimentsPlugin } public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) { + // 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' } = {} }) => { @@ -87,9 +99,6 @@ export class CloudExperimentsPlugin }) ) ); - registerUsageCollector(deps.usageCollection, () => ({ - launchDarklyClient: this.launchDarklyClient, // TODO: Return the client from the OpenFeature Provider - })); initializeMetadata({ metadataService: this.metadataService, @@ -99,6 +108,16 @@ export class CloudExperimentsPlugin logger: this.logger, }); + const launchDarklyOpenFeatureProvider = this.createOpenFeatureProvider(); + if (launchDarklyOpenFeatureProvider) { + core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); + } + + registerUsageCollector(deps.usageCollection, () => ({ + launchDarklyClient: launchDarklyOpenFeatureProvider?.getClient(), + currentContext: OpenFeature.getContext(), + })); + // TODO: Legacy client. Remove when the migration is complete if (deps.cloud.isCloudEnabled && deps.cloud.deploymentId) { // We only subscribe to the user metadata updates if Cloud is enabled. @@ -129,6 +148,24 @@ export class CloudExperimentsPlugin this.metadataService.stop(); } + private createOpenFeatureProvider() { + const { launch_darkly: ldConfig } = + this.initializerContext.config.get(); + + if (!ldConfig) return; + + return new LaunchDarklyProvider(ldConfig.client_id, { + 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 getVariation = async ( featureFlagName: CloudExperimentsFeatureFlagNames, defaultValue: Data 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 8522a44a962e0..599ba431b4a3d 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/observability_solution/observability_onboarding/kibana.jsonc b/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc index f9db6258c3cda..179834c55bef7 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_onboarding/kibana.jsonc @@ -25,7 +25,6 @@ ], "optionalPlugins": [ "cloud", - "cloudExperiments", "usageCollection" ], "requiredBundles": [ @@ -35,4 +34,4 @@ "common" ] } -} \ No newline at end of file +} 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 2e3dfb201b35e..aee17b9a7081a 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 { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; @@ -61,7 +60,6 @@ export interface ObservabilityOnboardingPluginStartDeps { security: SecurityPluginStart; cloud?: CloudStart; usageCollection?: UsageCollectionStart; - cloudExperiments?: CloudExperimentsPluginStart; } export type ObservabilityOnboardingContextValue = CoreStart & diff --git a/x-pack/plugins/spaces/kibana.jsonc b/x-pack/plugins/spaces/kibana.jsonc index 3c32314168417..3b1555b52fcfd 100644 --- a/x-pack/plugins/spaces/kibana.jsonc +++ b/x-pack/plugins/spaces/kibana.jsonc @@ -19,8 +19,7 @@ "home", "management", "usageCollection", - "cloud", - "cloudExperiments" + "cloud" ], "requiredBundles": [ "esUiShared", diff --git a/x-pack/plugins/spaces/public/experiments/is_solution_nav_enabled.ts b/x-pack/plugins/spaces/public/experiments/is_solution_nav_enabled.ts index 5351c20c99d0f..b2f6b621cf6fd 100644 --- a/x-pack/plugins/spaces/public/experiments/is_solution_nav_enabled.ts +++ b/x-pack/plugins/spaces/public/experiments/is_solution_nav_enabled.ts @@ -5,16 +5,13 @@ * 2.0. */ -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import type { CloudStart } from '@kbn/cloud-plugin/public'; +import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; const SOLUTION_NAV_FEATURE_FLAG_NAME = 'solutionNavEnabled'; -export const isSolutionNavEnabled = ( - cloud?: CloudStart, - cloudExperiments?: CloudExperimentsPluginStart -) => { - return Boolean(cloud?.isCloudEnabled) && cloudExperiments - ? cloudExperiments.getVariation(SOLUTION_NAV_FEATURE_FLAG_NAME, false) - : Promise.resolve(false); +export const isSolutionNavEnabled = (featureFlags: FeatureFlagsStart, cloud?: CloudStart) => { + return Boolean(cloud?.isCloudEnabled) + ? featureFlags.getBooleanValue(SOLUTION_NAV_FEATURE_FLAG_NAME, false) + : false; }; diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index c915a4ea73880..e28e0922b6cc8 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; import type { FeaturesPluginStart } from '@kbn/features-plugin/public'; @@ -33,7 +32,6 @@ export interface PluginsStart { features: FeaturesPluginStart; management?: ManagementStart; cloud?: CloudStart; - cloudExperiments?: CloudExperimentsPluginStart; } /** @@ -75,7 +73,7 @@ export class SpacesPlugin implements Plugin isSolutionNavEnabled(cloud, cloudExperiments)) + .then(([{ featureFlags }, { cloud }]) => isSolutionNavEnabled(featureFlags, cloud)) .catch((err) => { this.initializerContext.logger.get().error(`Failed to retrieve cloud experiment: ${err}`); diff --git a/yarn.lock b/yarn.lock index a4122b011ab58..f7f91d507d6b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7182,6 +7182,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" @@ -12752,7 +12757,7 @@ async@^1.4.2: resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= -async@^3.2.0, async@^3.2.3, async@^3.2.4: +async@^3.2.0, async@^3.2.3: version "3.2.4" resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== @@ -14358,16 +14363,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" @@ -21990,11 +21995,6 @@ latest-version@^7.0.0: dependencies: package-json "^8.1.0" -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" @@ -22017,19 +22017,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" @@ -23935,13 +23922,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" @@ -30612,7 +30592,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== From 7abfbf437c19a298b0d00db3999b1be0269c7c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Thu, 18 Jul 2024 23:40:43 +0200 Subject: [PATCH 04/41] Remove left overs in navigation plugin --- src/plugins/navigation/public/plugin.test.ts | 54 +++++++++----------- src/plugins/navigation/server/types.ts | 2 - 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/plugins/navigation/public/plugin.test.ts b/src/plugins/navigation/public/plugin.test.ts index e5c3a88babaf1..1db3397eeb4cf 100644 --- a/src/plugins/navigation/public/plugin.test.ts +++ b/src/plugins/navigation/public/plugin.test.ts @@ -10,7 +10,6 @@ import { firstValueFrom, of } from 'rxjs'; import { coreMock } from '@kbn/core/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; -import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks'; import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks'; import type { Space } from '@kbn/spaces-plugin/public'; import type { BuildFlavor } from '@kbn/config'; @@ -42,13 +41,12 @@ const setup = ( const coreStart = coreMock.createStart(); const unifiedSearch = unifiedSearchPluginMock.createStartContract(); const cloud = cloudMock.createStart(); - const cloudExperiments = cloudExperimentsMock.createStartMock(); const spaces = spacesPluginMock.createStartContract(); - cloudExperiments.getVariation.mockImplementation((key) => { + coreStart.featureFlags.getBooleanValue$.mockImplementation((key) => { if (key === SOLUTION_NAV_FEATURE_FLAG_NAME) { - return Promise.resolve(config.featureOn); + return of(config.featureOn); } - return Promise.resolve(false); + return of(false); }); const getGlobalSetting$ = jest.fn(); @@ -64,7 +62,6 @@ const setup = ( coreStart, unifiedSearch, cloud, - cloudExperiments, spaces, config, setChromeStyle, @@ -95,7 +92,7 @@ describe('Navigation Plugin', () => { const featureOn = true; it('should change the active solution navigation', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ + const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup({ featureOn, }); @@ -103,7 +100,7 @@ describe('Navigation Plugin', () => { .fn() .mockReturnValue(of({ solution: 'es' } as Pick)); - plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); + plugin.start(coreStart, { unifiedSearch, cloud, spaces }); await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.project.changeActiveSolutionNavigation).toHaveBeenCalledWith('es'); @@ -111,14 +108,13 @@ describe('Navigation Plugin', () => { describe('addSolutionNavigation()', () => { it('should update the solution navigation definitions', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup({ + const { plugin, coreStart, unifiedSearch, cloud } = setup({ featureOn, }); const { addSolutionNavigation } = plugin.start(coreStart, { unifiedSearch, cloud, - cloudExperiments, }); await new Promise((resolve) => setTimeout(resolve)); @@ -142,33 +138,33 @@ describe('Navigation Plugin', () => { describe('set Chrome style', () => { it('should set the Chrome style to "classic" when the feature is not enabled', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + const { plugin, coreStart, unifiedSearch, cloud } = setup( { featureOn: false } // feature not enabled ); - plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); + plugin.start(coreStart, { unifiedSearch, cloud }); await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); }); it('should set the Chrome style to "classic" when spaces plugin is not available', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + const { plugin, coreStart, unifiedSearch, cloud } = setup( { featureOn: true } // feature not enabled but no spaces plugin ); - plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); + plugin.start(coreStart, { unifiedSearch, cloud }); await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); }); it('should set the Chrome style to "classic" when active space solution is "classic"', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ + const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup({ featureOn: true, }); // Spaces plugin is available but activeSpace is undefined spaces.getActiveSpace$ = jest.fn().mockReturnValue(of(undefined)); - plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); + plugin.start(coreStart, { unifiedSearch, cloud, spaces }); await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); @@ -177,24 +173,24 @@ describe('Navigation Plugin', () => { spaces.getActiveSpace$ = jest .fn() .mockReturnValue(of({ solution: 'classic' } as Pick)); - plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); + plugin.start(coreStart, { unifiedSearch, cloud, spaces }); await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); }); it('should NOT set the Chrome style when the feature is enabled BUT on serverless', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + const { plugin, coreStart, unifiedSearch, cloud } = setup( { featureOn: true }, // feature enabled { buildFlavor: 'serverless' } ); - plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments }); + plugin.start(coreStart, { unifiedSearch, cloud }); await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.setChromeStyle).not.toHaveBeenCalled(); }); it('should set the Chrome style to "project" when space solution is a known solution', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ + const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup({ featureOn: true, }); @@ -202,14 +198,14 @@ describe('Navigation Plugin', () => { spaces.getActiveSpace$ = jest .fn() .mockReturnValue(of({ solution } as Pick)); - plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); + plugin.start(coreStart, { unifiedSearch, cloud, spaces }); await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('project'); coreStart.chrome.setChromeStyle.mockReset(); } spaces.getActiveSpace$ = jest.fn().mockReturnValue(of({ solution: 'unknown' })); - plugin.start(coreStart, { unifiedSearch, cloud, cloudExperiments, spaces }); + plugin.start(coreStart, { unifiedSearch, cloud, spaces }); await new Promise((resolve) => setTimeout(resolve)); expect(coreStart.chrome.setChromeStyle).toHaveBeenCalledWith('classic'); }); @@ -231,11 +227,11 @@ describe('Navigation Plugin', () => { }); it('should be off if feature flag if "ON" but space solution is "classic" or "undefined"', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ + const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup({ featureOn, }); - cloudExperiments.getVariation.mockResolvedValue(true); // Feature flag ON + coreStart.featureFlags.getBooleanValue$.mockReturnValue(of(true)); // Feature flag ON { spaces.getActiveSpace$ = jest @@ -245,7 +241,6 @@ describe('Navigation Plugin', () => { const { isSolutionNavEnabled$ } = plugin.start(coreStart, { unifiedSearch, cloud, - cloudExperiments, spaces, }); await new Promise((resolve) => setTimeout(resolve)); @@ -262,7 +257,6 @@ describe('Navigation Plugin', () => { const { isSolutionNavEnabled$ } = plugin.start(coreStart, { unifiedSearch, cloud, - cloudExperiments, spaces, }); await new Promise((resolve) => setTimeout(resolve)); @@ -273,11 +267,11 @@ describe('Navigation Plugin', () => { }); it('should be on if feature flag if "ON" and space solution is set', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments, spaces } = setup({ + const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup({ featureOn, }); - cloudExperiments.getVariation.mockResolvedValue(true); // Feature flag ON + coreStart.featureFlags.getBooleanValue$.mockReturnValue(of(true)); // Feature flag ON spaces.getActiveSpace$ = jest .fn() @@ -286,7 +280,6 @@ describe('Navigation Plugin', () => { const { isSolutionNavEnabled$ } = plugin.start(coreStart, { unifiedSearch, cloud, - cloudExperiments, spaces, }); await new Promise((resolve) => setTimeout(resolve)); @@ -296,7 +289,7 @@ describe('Navigation Plugin', () => { }); it('on serverless should flag must be disabled', async () => { - const { plugin, coreStart, unifiedSearch, cloud, cloudExperiments } = setup( + const { plugin, coreStart, unifiedSearch, cloud } = setup( { featureOn }, { buildFlavor: 'serverless' } ); @@ -304,7 +297,6 @@ describe('Navigation Plugin', () => { const { isSolutionNavEnabled$ } = plugin.start(coreStart, { unifiedSearch, cloud, - cloudExperiments, }); await new Promise((resolve) => setTimeout(resolve)); diff --git a/src/plugins/navigation/server/types.ts b/src/plugins/navigation/server/types.ts index 80980b245d815..f17b10bbc2c1c 100644 --- a/src/plugins/navigation/server/types.ts +++ b/src/plugins/navigation/server/types.ts @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common'; import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server'; import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server'; @@ -21,7 +20,6 @@ export interface NavigationServerSetupDependencies { } export interface NavigationServerStartDependencies { - cloudExperiments?: CloudExperimentsPluginStart; cloud?: CloudStart; spaces?: SpacesPluginStart; } From 7e82b161ac2b4625be31558139eb8b216062ba42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Thu, 18 Jul 2024 23:45:08 +0200 Subject: [PATCH 05/41] Further cleanup + tsconfig.json updates --- .../core-feature-flags-browser-internal/tsconfig.json | 8 ++++++-- .../core-feature-flags-browser-mocks/tsconfig.json | 8 ++++++-- .../core-feature-flags-browser/tsconfig.json | 2 +- .../core-feature-flags-server-internal/tsconfig.json | 6 +++++- .../core-feature-flags-server-mocks/tsconfig.json | 6 +++++- .../core-lifecycle-browser-internal/tsconfig.json | 3 ++- .../lifecycle/core-lifecycle-browser-mocks/tsconfig.json | 3 ++- .../core/lifecycle/core-lifecycle-browser/tsconfig.json | 3 ++- .../core-lifecycle-server-internal/tsconfig.json | 3 ++- .../lifecycle/core-lifecycle-server-mocks/tsconfig.json | 1 + .../core/lifecycle/core-lifecycle-server/tsconfig.json | 3 ++- .../core/root/core-root-browser-internal/tsconfig.json | 1 + .../core/root/core-root-server-internal/tsconfig.json | 1 + src/core/tsconfig.json | 4 ++++ src/plugins/navigation/tsconfig.json | 1 - .../plugins/cloud_integrations/cloud_chat/tsconfig.json | 1 - .../cloud_integrations/cloud_experiments/tsconfig.json | 5 +++++ .../observability_onboarding/tsconfig.json | 1 - x-pack/plugins/security_solution/kibana.jsonc | 1 - .../public/common/lib/kibana/kibana_react.mock.ts | 3 --- x-pack/plugins/security_solution/public/types.ts | 2 -- x-pack/plugins/security_solution/tsconfig.json | 1 - x-pack/plugins/spaces/tsconfig.json | 2 +- 23 files changed, 46 insertions(+), 23 deletions(-) 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 index 5c989599ec9ad..5a04683ee87f4 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json @@ -10,10 +10,14 @@ }, "include": [ "**/*.ts", - "**/*.tsx", + "**/*.tsx", ], "exclude": [ "target/**/*" ], - "kbn_references": [] + "kbn_references": [ + "@kbn/core-base-browser-internal", + "@kbn/core-feature-flags-browser", + "@kbn/logging", + ] } 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 index 5c989599ec9ad..b7d1b3ca28cbb 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/tsconfig.json @@ -10,10 +10,14 @@ }, "include": [ "**/*.ts", - "**/*.tsx", + "**/*.tsx", ], "exclude": [ "target/**/*" ], - "kbn_references": [] + "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/tsconfig.json b/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json index 5c989599ec9ad..b05325b824a67 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json @@ -10,7 +10,7 @@ }, "include": [ "**/*.ts", - "**/*.tsx", + "**/*.tsx", ], "exclude": [ "target/**/*" 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 index 0d78dace105e1..3d47cddef8298 100644 --- a/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json @@ -13,5 +13,9 @@ "exclude": [ "target/**/*" ], - "kbn_references": [] + "kbn_references": [ + "@kbn/core-base-server-internal", + "@kbn/core-feature-flags-server", + "@kbn/logging", + ] } 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 index 0d78dace105e1..c672eb28c83a9 100644 --- a/packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/tsconfig.json @@ -13,5 +13,9 @@ "exclude": [ "target/**/*" ], - "kbn_references": [] + "kbn_references": [ + "@kbn/utility-types", + "@kbn/core-feature-flags-server", + "@kbn/core-feature-flags-server-internal", + ] } diff --git a/packages/core/lifecycle/core-lifecycle-browser-internal/tsconfig.json b/packages/core/lifecycle/core-lifecycle-browser-internal/tsconfig.json index 4fd531018418d..84c55c0e87b90 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/tsconfig.json b/packages/core/lifecycle/core-lifecycle-browser-mocks/tsconfig.json index b6df8220f8603..cc1f0ed785dbc 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/tsconfig.json b/packages/core/lifecycle/core-lifecycle-browser/tsconfig.json index 558694c2c1c91..b7ded984ac90b 100644 --- a/packages/core/lifecycle/core-lifecycle-browser/tsconfig.json +++ b/packages/core/lifecycle/core-lifecycle-browser/tsconfig.json @@ -29,7 +29,8 @@ "@kbn/core-custom-branding-browser", "@kbn/core-plugins-contracts-browser", "@kbn/core-security-browser", - "@kbn/core-user-profile-browser" + "@kbn/core-user-profile-browser", + "@kbn/core-feature-flags-browser" ], "exclude": [ "target/**/*", diff --git a/packages/core/lifecycle/core-lifecycle-server-internal/tsconfig.json b/packages/core/lifecycle/core-lifecycle-server-internal/tsconfig.json index 1e5c8cb22233a..6e97b6b1fec92 100644 --- a/packages/core/lifecycle/core-lifecycle-server-internal/tsconfig.json +++ b/packages/core/lifecycle/core-lifecycle-server-internal/tsconfig.json @@ -35,7 +35,8 @@ "@kbn/core-custom-branding-server", "@kbn/core-user-settings-server-internal", "@kbn/core-security-server-internal", - "@kbn/core-user-profile-server-internal" + "@kbn/core-user-profile-server-internal", + "@kbn/core-feature-flags-server" ], "exclude": [ "target/**/*", diff --git a/packages/core/lifecycle/core-lifecycle-server-mocks/tsconfig.json b/packages/core/lifecycle/core-lifecycle-server-mocks/tsconfig.json index bacda3278557b..89ec5b0e1b7ba 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/tsconfig.json b/packages/core/lifecycle/core-lifecycle-server/tsconfig.json index ed35724914dec..c8b95eed1e6d7 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/root/core-root-browser-internal/tsconfig.json b/packages/core/root/core-root-browser-internal/tsconfig.json index e576ecf8cf920..a44a523d05744 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/tsconfig.json b/packages/core/root/core-root-server-internal/tsconfig.json index 528e1aacc0a93..843a701db807b 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/src/core/tsconfig.json b/src/core/tsconfig.json index 870d648d4b2e1..92647e56fad82 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/navigation/tsconfig.json b/src/plugins/navigation/tsconfig.json index 03a88e87ad806..1ee0462330954 100644 --- a/src/plugins/navigation/tsconfig.json +++ b/src/plugins/navigation/tsconfig.json @@ -22,7 +22,6 @@ "@kbn/shared-ux-chrome-navigation", "@kbn/cloud-plugin", "@kbn/config", - "@kbn/cloud-experiments-plugin", "@kbn/spaces-plugin", "@kbn/core-ui-settings-common", "@kbn/config-schema", diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json b/x-pack/plugins/cloud_integrations/cloud_chat/tsconfig.json index ffa21f10a6b44..0dcc15f22cee5 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/tsconfig.json b/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json index e1c6ed7b04539..04d50d2b376b8 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json @@ -19,6 +19,11 @@ "@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/observability_solution/observability_onboarding/tsconfig.json b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json index 921379f83de08..f5783fcfa2e33 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 f682ca478a17f..eac9e031a4c3a 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -56,7 +56,6 @@ "charts" ], "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 f20679d68884c..e82474eb5a774 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'; @@ -123,7 +122,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(); @@ -237,7 +235,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 129f3e7728f9d..15b043d2cedba 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'; @@ -142,7 +141,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 ba89ca2864d74..1254bf1608014 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -35,7 +35,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/x-pack/plugins/spaces/tsconfig.json b/x-pack/plugins/spaces/tsconfig.json index 9f79252873307..2d909e2536f6d 100644 --- a/x-pack/plugins/spaces/tsconfig.json +++ b/x-pack/plugins/spaces/tsconfig.json @@ -37,7 +37,7 @@ "@kbn/utility-types-jest", "@kbn/security-plugin-types-public", "@kbn/cloud-plugin", - "@kbn/cloud-experiments-plugin", + "@kbn/core-feature-flags-browser", ], "exclude": [ "target/**/*", From 8f2245605af3b8638adbbfbe176aca3f5671cb09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 19 Jul 2024 14:25:10 +0200 Subject: [PATCH 06/41] More cleanup + cloudExperiments tests --- .../test_suites/core_plugins/rendering.ts | 4 +- .../common/constants.test.ts | 25 -- .../cloud_experiments/common/constants.ts | 55 ---- .../cloud_experiments/common/index.ts | 13 - .../metadata_service/metadata_service.test.ts | 85 ++++-- .../cloud_experiments/common/mocks.ts | 19 -- .../cloud_experiments/common/types.ts | 74 ------ .../public/launch_darkly_client/index.ts | 12 - .../launch_darkly_client.test.mock.ts | 27 -- .../launch_darkly_client.test.ts | 195 -------------- .../launch_darkly_client.ts | 98 ------- .../cloud_experiments/public/plugin.test.ts | 251 ++---------------- .../cloud_experiments/public/plugin.ts | 118 ++------ .../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 | 93 ------- .../server/launch_darkly_client/mocks.ts | 26 -- .../cloud_experiments/server/plugin.test.ts | 250 ++++------------- .../cloud_experiments/server/plugin.ts | 81 +----- .../usage/register_usage_collector.test.ts | 22 +- .../observability_onboarding/README.md | 11 +- .../solution_view_flag_enabled/config.ts | 5 +- 25 files changed, 172 insertions(+), 1625 deletions(-) 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 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/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index a551030b943ba..cab5ad0abb6c4 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -130,6 +130,8 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'enterpriseSearch.canDeployEntSearch (boolean)', 'enterpriseSearch.host (string)', 'enterpriseSearch.ui.enabled (boolean)', + // No PII. This is an escape patch to override the flag resolution mechanism for testing or quick fixes. + 'feature_flags.overrides (record)', 'home.disableWelcomeScreen (boolean)', 'management.deeplinks.navLinkStatus (string)', 'map.emsFileApiUrl (string)', @@ -234,8 +236,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/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 8ff277b4abe59..0000000000000 --- 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 3615d1055a0b1..0000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/common/constants.ts +++ /dev/null @@ -1,55 +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', - /** - * Used to enable the new stack navigation around solutions during the rollout period. - */ - 'solutionNavEnabled' = 'solutionNavEnabled', -} - -/** - * 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 78874d5e7dda0..0000000000000 --- 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/metadata_service.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/metadata_service.test.ts index 0c0f5f5127f0f..c7fdd236cd9a8 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: 'FAKE_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: 'FAKE_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/mocks.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/mocks.ts deleted file mode 100644 index fd18c3ee2420d..0000000000000 --- 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 e7b87eee12fc9..0000000000000 --- 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/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 ac961286b7043..0000000000000 --- 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 b6a43a7d0715b..0000000000000 --- 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 998733707f0c0..0000000000000 --- 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 554289e1f2b0c..0000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts +++ /dev/null @@ -1,98 +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 LDLogLevel, - type LDMultiKindContext, -} 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; -} - -// TODO: Legacy client. Remove when the migration is complete -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: LDMultiKindContext): Promise { - if (this.canceled) return; - - let launchDarklyClient: LDClient | null = null; - if (this.initialized) { - launchDarklyClient = await this.getClient(); - } - - if (launchDarklyClient) { - await launchDarklyClient.identify(userMetadata); - } else { - this.initialized = true; - const { initialize, basicLogger } = await import('launchdarkly-js-client-sdk'); - launchDarklyClient = initialize(this.ldConfig.client_id, userMetadata, { - 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 7c945afcf53f3..f8f7553c3df95 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,44 +22,29 @@ 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', () => { @@ -81,7 +54,6 @@ describe('Cloud Experiments public plugin', () => { 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); @@ -100,60 +72,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 +91,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 +128,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 1abbc7b60a07b..ee95019e6fa17 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/plugin.ts @@ -5,24 +5,14 @@ * 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 { Logger } from '@kbn/logging'; +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 type { Logger } from '@kbn/logging'; - import { LaunchDarklyClientProvider } from '@openfeature/launchdarkly-client-provider'; -import type { LDMultiKindContext } from 'launchdarkly-js-client-sdk'; -import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client'; -import type { - CloudExperimentsFeatureFlagNames, - CloudExperimentsMetric, - CloudExperimentsPluginStart, -} from '../common'; +import { type LDLogLevel, basicLogger } from 'launchdarkly-js-client-sdk'; import { initializeMetadata, MetadataService } from '../common/metadata_service'; -import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants'; interface CloudExperimentsPluginSetupDeps { cloud: CloudSetup; @@ -32,25 +22,25 @@ interface CloudExperimentsPluginStartDeps { 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 flagOverrides?: Record; - private readonly isDev: boolean; /** Constructor of the plugin **/ constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); - this.isDev = initializerContext.env.mode.dev; const config = initializerContext.config.get<{ launch_darkly?: LaunchDarklyClientConfig; - flag_overrides?: Record; metadata_refresh_interval: string; }>(); @@ -59,11 +49,6 @@ export class CloudExperimentsPlugin this.logger.get('metadata') ); - // TODO: Legacy client. Remove when the migration is complete - 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 @@ -72,16 +57,6 @@ export class CloudExperimentsPlugin 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); } - - // TODO: Legacy client. Remove when the migration is complete - if (ldConfig?.client_id) { - // Disabled to make it easier for manual tests of the new client - // this.launchDarklyClient = new LaunchDarklyClient( - // ldConfig, - // this.initializerContext.env.packageInfo.version, - // this.logger - // ); - } } /** @@ -102,87 +77,30 @@ export class CloudExperimentsPlugin if (launchDarklyOpenFeatureProvider) { core.featureFlags.setProvider(launchDarklyOpenFeatureProvider); } - - // TODO: Legacy client. Remove when the migration is complete - if (deps.cloud.isCloudEnabled && deps.cloud.deploymentId && this.launchDarklyClient) { - // Update the client's contexts when we get any updates in the metadata. - this.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 this.launchDarklyClient?.updateUserMetadata(userMetadata as LDMultiKindContext); - } catch (err) { - this.logger.warn(`Failed to set the context in the legacy client ${err}`); - } - }) - ) - .subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable - } else { - this.launchDarklyClient?.cancel(); - } } /** - * Returns the contract {@link CloudExperimentsPluginStart} + * Sets the metadata service update hooks * @param core {@link CoreStart} + * @param deps {@link CloudExperimentsPluginStartDeps} */ - public start( - core: CoreStart, - { dataViews }: CloudExperimentsPluginStartDeps - ): CloudExperimentsPluginStart { - this.logger.info( - `The value is ${core.featureFlags.getStringValue('building-materials', 'fallback')}` - ); - + public start(core: CoreStart, { dataViews }: CloudExperimentsPluginStartDeps) { this.metadataService.start({ hasDataFetcher: async () => ({ has_data: await dataViews.hasData.hasUserDataView() }), }); - - return { - getVariation: this.getVariation, - reportMetric: this.reportMetric, - }; } /** * 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); - }; - - 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 }, - }); - } - }; - + /** + * Sets up the OpenFeature LaunchDarkly provider + * @private + */ private createOpenFeatureProvider() { const { launch_darkly: ldConfig } = this.initializerContext.config.get<{ launch_darkly?: LaunchDarklyClientConfig; @@ -191,7 +109,9 @@ export class CloudExperimentsPlugin if (!ldConfig) return; return new LaunchDarklyClientProvider(ldConfig.client_id, { - logger: this.logger.get('launch-darkly'), + // logger: this.logger.get('launch-darkly'), + // Using basicLogger for now because we can't limit the level for now if we're using core's logger. + logger: basicLogger({ level: ldConfig.client_log_level }), streaming: true, // Necessary to react to flag changes application: { id: 'kibana-browser', 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 146de2c3ddc9a..1d8d46e5cbf3f 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 a5b5eeb88c2dd..a1bcb5d53fd72 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 d298aad1ad6c1..0000000000000 --- 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 c8759ab59f6a9..0000000000000 --- 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 0b928b7496397..0000000000000 --- 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 e47c842c139fb..0000000000000 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts +++ /dev/null @@ -1,93 +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, - LDFlagSet, - LDLogLevel, - LDMultiKindContext, -} 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[]; -} - -// TODO: Legacy client. Remove when the migration is complete -export class LaunchDarklyClient { - private readonly launchDarklyClient: LDClient; - private launchDarklyUser?: LDMultiKindContext; - - 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: LDMultiKindContext) { - this.launchDarklyUser = userMetadata; - } - - 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 3fe1838815b27..0000000000000 --- 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 37989482dc31c..89273a7b0cda2 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: 'FAKE_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 509712b75b038..d0a7cc44aa674 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts @@ -12,25 +12,16 @@ import type { Plugin, Logger, } from '@kbn/core/server'; -import { get, has } from 'lodash'; -import { filter, map } from 'rxjs'; +import { map } from 'rxjs'; import { OpenFeature } from '@openfeature/server-sdk'; import { LaunchDarklyProvider } from '@launchdarkly/openfeature-node-server'; -import type { LDMultiKindContext } from '@launchdarkly/node-server-sdk'; -import type { LogLevelId, LogMeta } from '@kbn/logging'; +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 { initializeMetadata, MetadataService } from '../common/metadata_service'; -import { LaunchDarklyClient } from './launch_darkly_client'; 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; @@ -42,17 +33,9 @@ interface CloudExperimentsPluginStartDeps { } export class CloudExperimentsPlugin - implements - Plugin< - void, - CloudExperimentsPluginStart, - CloudExperimentsPluginSetupDeps, - CloudExperimentsPluginStartDeps - > + implements Plugin { private readonly logger: Logger; - private readonly launchDarklyClient?: LaunchDarklyClient; - private readonly flagOverrides?: Record; private readonly metadataService: MetadataService; constructor(private readonly initializerContext: PluginInitializerContext) { @@ -64,10 +47,6 @@ export class CloudExperimentsPlugin this.logger.get('metadata') ); - // TODO: Legacy client. Remove when the migration is complete - 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 @@ -76,17 +55,6 @@ export class CloudExperimentsPlugin 'xpack.cloud_integrations.experiments.launch_darkly configuration should exist' ); } - - // TODO: Legacy client. Remove when the migration is complete - if (ldConfig) { - // this.launchDarklyClient = new LaunchDarklyClient( - // { - // ...ldConfig, - // kibana_version: initializerContext.env.packageInfo.version, - // }, - // this.logger.get('launch_darkly') - // ); - } } public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) { @@ -117,34 +85,15 @@ export class CloudExperimentsPlugin launchDarklyClient: launchDarklyOpenFeatureProvider?.getClient(), currentContext: OpenFeature.getContext(), })); - - // TODO: Legacy client. Remove when the migration is complete - if (deps.cloud.isCloudEnabled && deps.cloud.deploymentId) { - // 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 as LDMultiKindContext) - ) - ) - .subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable - } } 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(); } @@ -166,30 +115,6 @@ export class CloudExperimentsPlugin }); } - 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 async addHasDataMetadata( core: CoreStart, dataViews: DataViewsServerPluginStart 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 ab18c2dbed613..0236ce9e95692 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/observability_solution/observability_onboarding/README.md b/x-pack/plugins/observability_solution/observability_onboarding/README.md index 1284f71750e41..ad29a8a0c90a6 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/test/functional/apps/spaces/solution_view_flag_enabled/config.ts b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/config.ts index 0d286f5ffa621..3ef52303062bb 100644 --- a/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/config.ts +++ b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/config.ts @@ -17,10 +17,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.get('kbnTestServer'), serverArgs: [ ...functionalConfig.get('kbnTestServer.serverArgs'), - '--xpack.cloud_integrations.experiments.enabled=true', - '--xpack.cloud_integrations.experiments.launch_darkly.sdk_key=a_string', - '--xpack.cloud_integrations.experiments.launch_darkly.client_id=a_string', - '--xpack.cloud_integrations.experiments.flag_overrides.solutionNavEnabled=true', + '--feature_flags.overrides.solutionNavEnabled=true', // Note: the base64 string in the cloud.id config contains the ES endpoint required in the functional tests '--xpack.cloud.id=ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM=', '--xpack.cloud.base_url=https://cloud.elastic.co', From 53a9daaf783442f6229bf8ce5e980aba6f4204aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 19 Jul 2024 14:28:06 +0200 Subject: [PATCH 07/41] Further cleanup --- .../cloud_integrations/cloud_experiments/public/plugin.test.ts | 3 --- 1 file changed, 3 deletions(-) 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 f8f7553c3df95..59a20b198e70b 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 @@ -49,7 +49,6 @@ describe('Cloud Experiments public plugin', () => { describe('setup', () => { let plugin: CloudExperimentsPlugin; - let metadataServiceSetupSpy: jest.SpyInstance; beforeEach(() => { const initializerContext = coreMock.createPluginInitializerContext({ @@ -57,8 +56,6 @@ describe('Cloud Experiments public plugin', () => { metadata_refresh_interval: duration(1, 'h'), }); plugin = new CloudExperimentsPlugin(initializerContext); - // eslint-disable-next-line dot-notation - metadataServiceSetupSpy = jest.spyOn(plugin['metadataService'], 'setup'); }); afterEach(() => { From c2f1bd7fe75be7313e41be51877b1f7e1f40f5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 19 Jul 2024 22:04:59 +0200 Subject: [PATCH 08/41] Add unit tests --- .../src/feature_flags_service.test.ts | 259 ++++++++++++++++++ .../src/feature_flags_service.ts | 7 +- .../tsconfig.json | 1 + .../core-feature-flags-browser/jest.config.js | 13 - .../core-feature-flags-browser/tsconfig.json | 1 - .../src/feature_flags_service.test.ts | 220 +++++++++++++++ .../src/feature_flags_service.ts | 5 +- .../tsconfig.json | 1 + .../core-feature-flags-server/jest.config.js | 13 - .../core-feature-flags-server/tsconfig.json | 1 - .../cloud_experiments/tsconfig.json | 1 - 11 files changed, 488 insertions(+), 34 deletions(-) create mode 100644 packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts delete mode 100644 packages/core/feature-flags/core-feature-flags-browser/jest.config.js create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts delete mode 100644 packages/core/feature-flags/core-feature-flags-server/jest.config.js 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 0000000000000..dff3cb390eafd --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/src/feature_flags_service.test.ts @@ -0,0 +1,259 @@ +/* + * 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 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 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 { 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; + + beforeEach(() => { + const getClientSpy = jest.spyOn(OpenFeature, 'getClient'); + featureFlagsService = new FeatureFlagsService(coreContextMock.create()); + featureFlagsClient = getClientSpy.mock.results[0].value; + }); + + 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(); + const spy = jest.spyOn(OpenFeature, 'setProviderAndWait'); + const fakeProvider = { metadata: { name: 'fake provider' } } as Provider; + setProvider(fakeProvider); + expect(spy).toHaveBeenCalledWith(fakeProvider); + }); + + test('awaits initialization in the start context', async () => { + const { setProvider } = featureFlagsService.setup(); + 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(); + const spy = jest.spyOn(OpenFeature, 'setProviderAndWait').mockImplementation(async () => { + await new Promise(() => {}); // never resolves + }); + 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); + }); + }); + + describe('context handling', () => { + let setContextSpy: jest.SpyInstance; + + beforeEach(() => { + setContextSpy = jest.spyOn(OpenFeature, 'setContext'); + }); + + test('appends context to the provider', async () => { + const { appendContext } = featureFlagsService.setup(); + await appendContext({ kind: 'multi' }); + expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi' }); + }); + + test('appends context to the provider (start method)', async () => { + featureFlagsService.setup(); + 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(); + 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(); + 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(); + 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(); + 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'); + featureFlagsService.setup(); + 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); + }); + }); +}); 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 index 93973d8c372b3..27238e9c2b9c3 100644 --- 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 @@ -55,7 +55,7 @@ export class FeatureFlagsService { const observeFeatureFlag$ = (flagName: string) => featureFlagsChanged$.pipe( filter((flagNames) => flagNames.includes(flagName)), - startWith([]) // only to emit on the first call + startWith([flagName]) // only to emit on the first call ); await this.waitForProviderInitialization(); @@ -116,7 +116,7 @@ export class FeatureFlagsService { let timeoutId: NodeJS.Timeout | undefined; await Promise.race([ this.isProviderReadyPromise, - await new Promise((resolve) => { + new Promise((resolve) => { timeoutId = setTimeout(resolve, 2 * 1000); }).then(() => { this.logger.warn('The feature flags provider took too long to initialize'); @@ -138,7 +138,8 @@ export class FeatureFlagsService { fallbackValue: T ): T { // TODO: intercept with config overrides - const value = evaluationFn(flagName, fallbackValue); + // We have to bind the evaluation or the client will lose its internal context + const value = evaluationFn.bind(this.featureFlagsClient)(flagName, fallbackValue); apm.addLabels({ [`flag_${flagName}`]: value }); // TODO: increment usage counter return value; 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 index 5a04683ee87f4..b873c0b4c2e45 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json @@ -19,5 +19,6 @@ "@kbn/core-base-browser-internal", "@kbn/core-feature-flags-browser", "@kbn/logging", + "@kbn/core-base-browser-mocks", ] } diff --git a/packages/core/feature-flags/core-feature-flags-browser/jest.config.js b/packages/core/feature-flags/core-feature-flags-browser/jest.config.js deleted file mode 100644 index 9fbcd085641fc..0000000000000 --- a/packages/core/feature-flags/core-feature-flags-browser/jest.config.js +++ /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 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 or the Server - * Side Public License, v 1. - */ - -module.exports = { - preset: '@kbn/test', - rootDir: '../../../..', - roots: ['/packages/core/feature-flags/core-feature-flags-browser'], -}; diff --git a/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json b/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json index b05325b824a67..9fa73d55be770 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-browser/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "target/types", "types": [ - "jest", "node", "react" ] 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 0000000000000..5586990e49f41 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts @@ -0,0 +1,220 @@ +/* + * 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 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 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 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()); + 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); + }); + }); + + 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); + }); + }); +}); 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 index 21a878fdcc235..2d49a02861e80 100644 --- 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 @@ -54,7 +54,7 @@ export class FeatureFlagsService { const observeFeatureFlag$ = (flagName: string) => featureFlagsChanged$.pipe( filter((flagNames) => flagNames.includes(flagName)), - startWith([]) // only to emit on the first call + startWith([flagName]) // only to emit on the first call ); return { @@ -125,7 +125,8 @@ export class FeatureFlagsService { fallbackValue: T ): Promise { // TODO: intercept with config overrides - const value = await evaluationFn(flagName, fallbackValue); + // We have to bind the evaluation or the client will lose its internal context + const value = await evaluationFn.bind(this.featureFlagsClient)(flagName, fallbackValue); apm.addLabels({ [`flag_${flagName}`]: value }); // TODO: increment usage counter return value; 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 index 3d47cddef8298..e5658783ddce9 100644 --- a/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json @@ -17,5 +17,6 @@ "@kbn/core-base-server-internal", "@kbn/core-feature-flags-server", "@kbn/logging", + "@kbn/core-base-server-mocks", ] } diff --git a/packages/core/feature-flags/core-feature-flags-server/jest.config.js b/packages/core/feature-flags/core-feature-flags-server/jest.config.js deleted file mode 100644 index 691e5140f198b..0000000000000 --- a/packages/core/feature-flags/core-feature-flags-server/jest.config.js +++ /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 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 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'], -}; diff --git a/packages/core/feature-flags/core-feature-flags-server/tsconfig.json b/packages/core/feature-flags/core-feature-flags-server/tsconfig.json index 0d78dace105e1..f5bb1b00512e4 100644 --- a/packages/core/feature-flags/core-feature-flags-server/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-server/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "target/types", "types": [ - "jest", "node" ] }, diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json b/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json index 04d50d2b376b8..d47131016228d 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/tsconfig.json @@ -18,7 +18,6 @@ "@kbn/config-schema", "@kbn/logging", "@kbn/logging-mocks", - "@kbn/utility-types", "@kbn/core-plugins-browser", "@kbn/core-plugins-server", "@kbn/core-feature-flags-browser", From 4df2227b6e1645bbf0c9f35a1fd7c489568c31fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 19 Jul 2024 22:42:21 +0200 Subject: [PATCH 09/41] Add config overrides on the server-side --- .../index.ts | 1 + .../src/feature_flags_config.ts | 21 +++++++++++++++++++ .../src/feature_flags_service.test.ts | 20 +++++++++++++++++- .../src/feature_flags_service.ts | 19 ++++++++++++++--- .../src/register_service_config.ts | 2 ++ 5 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts 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 index b2cdfd473fc5f..432bebbebb609 100644 --- a/packages/core/feature-flags/core-feature-flags-server-internal/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server-internal/index.ts @@ -6,4 +6,5 @@ * Side Public License, v 1. */ +export { featureFlagsConfig } from './src/feature_flags_config'; export { FeatureFlagsService } from './src/feature_flags_service'; 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 0000000000000..55b409b826e82 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts @@ -0,0 +1,21 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import type { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; +import { schema, TypeOf } from '@kbn/config-schema'; + +const configSchema = schema.object({ + overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())), +}); + +export type FeatureFlagsConfig = TypeOf; + +export const featureFlagsConfig: ServiceConfigDescriptor = { + path: 'feature_flags', + schema: configSchema, +}; 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 index 5586990e49f41..52424ccd52714 100644 --- 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 @@ -10,6 +10,7 @@ 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 '..'; @@ -19,7 +20,17 @@ describe('FeatureFlagsService Server', () => { beforeEach(() => { const getClientSpy = jest.spyOn(OpenFeature, 'getClient'); - featureFlagsService = new FeatureFlagsService(mockCoreContext.create()); + featureFlagsService = new FeatureFlagsService( + mockCoreContext.create({ + configService: configServiceMock.create({ + atPath: { + overrides: { + 'my-overriden-flag': true, + }, + }, + }), + }) + ); featureFlagsClient = getClientSpy.mock.results[0].value; }); @@ -216,5 +227,12 @@ describe('FeatureFlagsService Server', () => { await expect(firstValueFrom(flag$)).resolves.toEqual(value); expect(observedValues).toHaveLength(2); }); + + test('with overrides', async () => { + await expect(startContract.getBooleanValue('my-overriden-flag', false)).resolves.toEqual( + true + ); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-overriden-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 index 2d49a02861e80..e083416a3247a 100644 --- 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 @@ -17,16 +17,27 @@ import apm from 'elastic-apm-node'; import { type Client, OpenFeature, ServerProviderEvents } from '@openfeature/server-sdk'; import deepMerge from 'deepmerge'; import { filter, mergeMap, startWith, Subject } from 'rxjs'; +import { type FeatureFlagsConfig, featureFlagsConfig } from './feature_flags_config'; export class FeatureFlagsService { private readonly featureFlagsClient: Client; private readonly logger: Logger; + private overrides: Record = {}; private context: EvaluationContext = { kind: 'multi' }; constructor(core: CoreContext) { this.logger = core.logger.get('feature-flags-service'); this.featureFlagsClient = OpenFeature.getClient(); OpenFeature.setLogger(this.logger.get('open-feature')); + + // Register "overrides" to be changed via the dynamic config endpoint (enabled in test environments only) + core.configService.addDynamicConfigPaths(featureFlagsConfig.path, ['overrides']); + + core.configService + .atPath(featureFlagsConfig.path) + .subscribe(({ overrides = {} }) => { + this.overrides = overrides; + }); } /** @@ -124,9 +135,11 @@ export class FeatureFlagsService { flagName: string, fallbackValue: T ): Promise { - // TODO: intercept with config overrides - // We have to bind the evaluation or the client will lose its internal context - const value = await evaluationFn.bind(this.featureFlagsClient)(flagName, fallbackValue); + 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; 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 3398c8e7b506e..502aa094a17e7 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 @@ -32,6 +32,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'; @@ -47,6 +48,7 @@ export function registerServiceConfig(configService: ConfigService) { coreAppConfig, elasticApmConfig, executionContextConfig, + featureFlagsConfig, externalUrlConfig, httpConfig, i18nConfig, From 1af57295f87192f56e78045525bb2999229c6ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 22 Jul 2024 22:19:15 +0200 Subject: [PATCH 10/41] lint ts projects --- .../core-feature-flags-server-internal/tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) 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 index e5658783ddce9..72a97ef56eb4f 100644 --- a/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-server-internal/tsconfig.json @@ -18,5 +18,7 @@ "@kbn/core-feature-flags-server", "@kbn/logging", "@kbn/core-base-server-mocks", + "@kbn/config-schema", + "@kbn/config-mocks", ] } From 5f80536a0503e89e37042473b198e5af34133e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 22 Jul 2024 23:48:04 +0200 Subject: [PATCH 11/41] Support overrides on the browser --- .../src/feature_flags_service.test.ts | 32 ++- .../src/feature_flags_service.ts | 26 ++- .../index.ts | 2 +- .../src/feature_flags_service.test.ts | 11 +- .../src/feature_flags_service.ts | 14 +- .../core-feature-flags-server-mocks/index.ts | 19 +- .../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 | 4 +- .../src/plugin_context.ts | 4 +- .../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 + .../src/core_system.ts | 2 +- .../core-root-server-internal/src/server.ts | 4 +- 20 files changed, 383 insertions(+), 31 deletions(-) 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 index dff3cb390eafd..f3be02fe3834a 100644 --- 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 @@ -11,6 +11,8 @@ 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) { @@ -22,11 +24,13 @@ async function isSettledPromise(p: Promise) { 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 () => { @@ -39,7 +43,7 @@ describe('FeatureFlagsService Browser', () => { describe('provider handling', () => { test('appends a provider (without awaiting)', () => { expect.assertions(1); - const { setProvider } = featureFlagsService.setup(); + const { setProvider } = featureFlagsService.setup({ injectedMetadata }); const spy = jest.spyOn(OpenFeature, 'setProviderAndWait'); const fakeProvider = { metadata: { name: 'fake provider' } } as Provider; setProvider(fakeProvider); @@ -47,7 +51,7 @@ describe('FeatureFlagsService Browser', () => { }); test('awaits initialization in the start context', async () => { - const { setProvider } = featureFlagsService.setup(); + const { setProvider } = featureFlagsService.setup({ injectedMetadata }); let externalResolve: Function = () => void 0; const spy = jest.spyOn(OpenFeature, 'setProviderAndWait').mockImplementation(async () => { await new Promise((resolve) => { @@ -65,7 +69,7 @@ describe('FeatureFlagsService Browser', () => { }); test('do not hold for too long during initialization', async () => { - const { setProvider } = featureFlagsService.setup(); + const { setProvider } = featureFlagsService.setup({ injectedMetadata }); const spy = jest.spyOn(OpenFeature, 'setProviderAndWait').mockImplementation(async () => { await new Promise(() => {}); // never resolves }); @@ -87,20 +91,20 @@ describe('FeatureFlagsService Browser', () => { }); test('appends context to the provider', async () => { - const { appendContext } = featureFlagsService.setup(); + 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(); + 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(); + const { appendContext } = featureFlagsService.setup({ injectedMetadata }); const context = { kind: 'multi' as const, kibana: { @@ -115,7 +119,7 @@ describe('FeatureFlagsService Browser', () => { }); test('appends to the existing context', async () => { - const { appendContext } = featureFlagsService.setup(); + const { appendContext } = featureFlagsService.setup({ injectedMetadata }); const initialContext = { kind: 'multi' as const, kibana: { @@ -139,7 +143,7 @@ describe('FeatureFlagsService Browser', () => { }); test('converts single-contexts to multi-context', async () => { - const { appendContext } = featureFlagsService.setup(); + const { appendContext } = featureFlagsService.setup({ injectedMetadata }); await appendContext({ kind: 'organization', key: 'organization-1' }); expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi', @@ -150,7 +154,7 @@ describe('FeatureFlagsService Browser', () => { }); test('if no `kind` provided, it defaults to the kibana context', async () => { - const { appendContext } = featureFlagsService.setup(); + const { appendContext } = featureFlagsService.setup({ injectedMetadata }); await appendContext({ key: 'key-1', has_data: false }); expect(setContextSpy).toHaveBeenCalledWith({ kind: 'multi', @@ -169,7 +173,10 @@ describe('FeatureFlagsService Browser', () => { beforeEach(async () => { addHandlerSpy = jest.spyOn(featureFlagsClient, 'addHandler'); - featureFlagsService.setup(); + injectedMetadata.getFeatureFlags.mockReturnValue({ + overrides: { 'my-overridden-flag': true }, + }); + featureFlagsService.setup({ injectedMetadata }); startContract = await featureFlagsService.start(); apmSpy = jest.spyOn(apm, 'addLabels'); }); @@ -255,5 +262,10 @@ describe('FeatureFlagsService Browser', () => { await expect(firstValueFrom(flag$)).resolves.toEqual(value); expect(observedValues).toHaveLength(2); }); + + test('with overrides', async () => { + expect(startContract.getBooleanValue('my-overridden-flag', false)).toEqual(true); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-overridden-flag': true }); + }); }); }); 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 index 27238e9c2b9c3..0bcb0ea18c48a 100644 --- 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 @@ -7,22 +7,32 @@ */ 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, } from '@kbn/core-feature-flags-browser'; -import type { Logger } from '@kbn/logging'; 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 { + injectedMetadata: InternalInjectedMetadataSetup; +} + export class FeatureFlagsService { private readonly featureFlagsClient: Client; private readonly logger: Logger; private isProviderReadyPromise?: Promise; private context: EvaluationContext = { kind: 'multi' }; + private overrides: Record = {}; constructor(core: CoreContext) { this.logger = core.logger.get('feature-flags-service'); @@ -33,7 +43,11 @@ export class FeatureFlagsService { /** * Setup lifecycle method */ - public setup(): FeatureFlagsSetup { + public setup({ injectedMetadata }: FeatureFlagsSetupDeps): FeatureFlagsSetup { + const featureFlagsInjectedMetadata = injectedMetadata.getFeatureFlags(); + if (featureFlagsInjectedMetadata) { + this.overrides = featureFlagsInjectedMetadata.overrides; + } return { setProvider: (provider) => { this.isProviderReadyPromise = OpenFeature.setProviderAndWait(provider); @@ -137,9 +151,11 @@ export class FeatureFlagsService { flagName: string, fallbackValue: T ): T { - // TODO: intercept with config overrides - // We have to bind the evaluation or the client will lose its internal context - const value = evaluationFn.bind(this.featureFlagsClient)(flagName, fallbackValue); + 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; 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 index 432bebbebb609..fe7a95e2f7348 100644 --- a/packages/core/feature-flags/core-feature-flags-server-internal/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server-internal/index.ts @@ -7,4 +7,4 @@ */ export { featureFlagsConfig } from './src/feature_flags_config'; -export { FeatureFlagsService } from './src/feature_flags_service'; +export { FeatureFlagsService, type InternalFeatureFlagsSetup } from './src/feature_flags_service'; 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 index 52424ccd52714..65f601e43b0f1 100644 --- 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 @@ -25,7 +25,7 @@ describe('FeatureFlagsService Server', () => { configService: configServiceMock.create({ atPath: { overrides: { - 'my-overriden-flag': true, + 'my-overridden-flag': true, }, }, }), @@ -229,10 +229,15 @@ describe('FeatureFlagsService Server', () => { }); test('with overrides', async () => { - await expect(startContract.getBooleanValue('my-overriden-flag', false)).resolves.toEqual( + await expect(startContract.getBooleanValue('my-overridden-flag', false)).resolves.toEqual( true ); - expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-overriden-flag': true }); + expect(apmSpy).toHaveBeenCalledWith({ 'flag_my-overridden-flag': true }); }); }); + + 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 index e083416a3247a..b747c581aaa5b 100644 --- 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 @@ -19,6 +19,17 @@ import deepMerge from 'deepmerge'; import { filter, mergeMap, 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; +} + export class FeatureFlagsService { private readonly featureFlagsClient: Client; private readonly logger: Logger; @@ -43,8 +54,9 @@ export class FeatureFlagsService { /** * Setup lifecycle method */ - public setup(): FeatureFlagsSetup { + public setup(): InternalFeatureFlagsSetup { return { + getOverrides: () => this.overrides, setProvider: (provider) => { OpenFeature.setProvider(provider); }, 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 index 80b170dc2c73c..1517e3b1dc539 100644 --- a/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts @@ -8,7 +8,17 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { FeatureFlagsSetup, FeatureFlagsStart } from '@kbn/core-feature-flags-server'; -import type { FeatureFlagsService } from '@kbn/core-feature-flags-server-internal'; +import type { + FeatureFlagsService, + InternalFeatureFlagsSetup, +} from '@kbn/core-feature-flags-server-internal'; + +const createFeatureFlagsInternalSetup = (): jest.Mocked => { + return { + ...createFeatureFlagsSetup(), + getOverrides: jest.fn().mockReturnValue({}), + }; +}; const createFeatureFlagsSetup = (): jest.Mocked => { return { @@ -19,17 +29,19 @@ const createFeatureFlagsSetup = (): jest.Mocked => { const createFeatureFlagsStart = (): jest.Mocked => { return { - addHandler: jest.fn(), 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(), + getStringValue$: jest.fn(), + getNumberValue$: jest.fn(), }; }; const createFeatureFlagsServiceMock = (): jest.Mocked> => { return { - setup: jest.fn().mockImplementation(createFeatureFlagsSetup), + setup: jest.fn().mockImplementation(createFeatureFlagsInternalSetup), start: jest.fn().mockImplementation(createFeatureFlagsStart), stop: jest.fn().mockImplementation(Promise.resolve), }; @@ -37,6 +49,7 @@ const createFeatureFlagsServiceMock = (): jest.Mocked { it('returns elasticsearch info from injectedMetadata', () => { @@ -159,3 +160,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 104500ef19215..f2cd4a97a309f 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 @@ -94,6 +94,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 12bee868702b6..02c2bc7f146d6 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 @@ -57,6 +57,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 e2dad19650a2c..b3b87c3651bbe 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 @@ -29,6 +29,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 c2f1e85e1e60d..9b74144f1c8aa 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 @@ -62,6 +62,9 @@ export interface InjectedMetadata { mode: EnvironmentMode; packageInfo: PackageInfo; }; + featureFlags?: { + overrides: Record; + }; anonymousStatusPage: boolean; i18n: { translationsUrl: string; diff --git a/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_setup.ts b/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_setup.ts index ff6d7a008d012..5ec244d511a42 100644 --- a/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_setup.ts +++ b/packages/core/lifecycle/core-lifecycle-server-internal/src/internal_core_setup.ts @@ -28,7 +28,7 @@ import type { InternalCustomBrandingSetup } from '@kbn/core-custom-branding-serv import type { InternalUserSettingsServiceSetup } from '@kbn/core-user-settings-server-internal'; import type { InternalSecurityServiceSetup } from '@kbn/core-security-server-internal'; import type { InternalUserProfileServiceSetup } from '@kbn/core-user-profile-server-internal'; -import type { FeatureFlagsSetup } from '@kbn/core-feature-flags-server'; +import type { InternalFeatureFlagsSetup } from '@kbn/core-feature-flags-server-internal'; /** @internal */ export interface InternalCoreSetup { @@ -39,7 +39,7 @@ export interface InternalCoreSetup { http: InternalHttpServiceSetup; elasticsearch: InternalElasticsearchServiceSetup; executionContext: InternalExecutionContextSetup; - featureFlags: FeatureFlagsSetup; + featureFlags: InternalFeatureFlagsSetup; i18n: I18nServiceSetup; savedObjects: InternalSavedObjectsServiceSetup; status: InternalStatusServiceSetup; 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 049ed57598a83..35a56c7eccea8 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 @@ -336,11 +336,13 @@ export function createPluginStartContext({ }, executionContext: deps.executionContext, featureFlags: { - addHandler: deps.featureFlags.addHandler, 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, 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 e92e760b400e5..c858b6a8470d2 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 9adf0a0ea3d69..e2dd857fb1fa8 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 @@ -81,6 +81,10 @@ function renderTestCases( }); }); + afterEach(() => { + mockRenderingSetupDeps.featureFlags.getOverrides.mockReset(); + }); + it('renders "core" page', async () => { const [render] = await getRender(); const content = await render(createKibanaRequest(), uiSettings); @@ -244,6 +248,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 4b7e75ea9fb84..8917426e7d53a 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 @@ -50,6 +50,7 @@ type RenderOptions = | (RenderingPrebootDeps & { status?: never; elasticsearch?: never; + featureFlags?: never; customBranding?: never; userSettings?: never; }); @@ -84,6 +85,7 @@ export class RenderingService { public async setup({ elasticsearch, + featureFlags, http, status, uiPlugins, @@ -105,6 +107,7 @@ export class RenderingService { return { render: this.render.bind(this, { elasticsearch, + featureFlags, http, uiPlugins, status, @@ -124,8 +127,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, @@ -250,6 +261,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 d7b44157f4a6d..92f780598a84b 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 @@ -13,6 +13,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/server/mocks'; const context = mockCoreContext.create(); const httpPreboot = httpServiceMock.createInternalPrebootContract(); @@ -38,6 +39,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 98887a9f9da29..3587957ba13aa 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/types.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/types.ts @@ -23,6 +23,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 { @@ -48,6 +49,7 @@ export interface RenderingPrebootDeps { /** @internal */ export interface RenderingSetupDeps { elasticsearch: InternalElasticsearchServiceSetup; + featureFlags: InternalFeatureFlagsSetup; http: InternalHttpServiceSetup; status: InternalStatusServiceSetup; uiPlugins: UiPlugins; 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 d6167bce2c58a..cc11491d3ef1b 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 @@ -253,7 +253,7 @@ export class CoreSystem { const application = this.application.setup({ http, analytics }); this.coreApp.setup({ application, http, injectedMetadata, notifications }); - const featureFlags = this.featureFlags.setup(); + const featureFlags = this.featureFlags.setup({ injectedMetadata }); const core: InternalCoreSetup = { analytics, 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 dc167b42a14ea..a039ca54140b6 100644 --- a/packages/core/root/core-root-server-internal/src/server.ts +++ b/packages/core/root/core-root-server-internal/src/server.ts @@ -327,9 +327,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, @@ -343,8 +345,6 @@ export class Server { rendering: renderingSetup, }); - const featureFlagsSetup = this.featureFlags.setup(); - const loggingSetup = this.logging.setup(); const coreSetup: InternalCoreSetup = { From d1a7d8c3173428600b82a310d46a65d535a9e7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 23 Jul 2024 20:39:10 +0200 Subject: [PATCH 12/41] [renovate] OpenFeature should match LaunchDarkly upgrade strategy --- renovate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index cb293fadccdf2..8c9e6fd0651e0 100644 --- a/renovate.json +++ b/renovate.json @@ -63,7 +63,7 @@ "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:skip"], + "labels": ["release_note:skip", "Team:Security", "Team:Core", "backport:prev-minor"], "minimumReleaseAge": "7 days", "enabled": true }, From 37e23f7aab036da46e25f9cf057b19085e998abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 24 Jul 2024 20:54:47 +0200 Subject: [PATCH 13/41] Use organizationId and orchestratorTarget from the cloud plugin --- x-pack/plugins/cloud/server/mocks.ts | 2 ++ .../common/metadata_service/initialize_metadata.ts | 6 ++---- .../common/metadata_service/metadata_service.test.ts | 4 ++-- .../cloud_experiments/server/plugin.test.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/cloud/server/mocks.ts b/x-pack/plugins/cloud/server/mocks.ts index e77f58902bf3e..b54b21f5ce827 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_experiments/common/metadata_service/initialize_metadata.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/initialize_metadata.ts index f5384847c1a3f..1cba103fe75dd 100644 --- 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 @@ -33,8 +33,6 @@ export function initializeMetadata({ }) { const offering = initializerContext.env.packageInfo.buildFlavor; - const orgId = 'FAKE_ID'; // TODO: Retrieve it when available - metadataService.setup({ instanceKey: cloud.serverless?.projectId || cloud.deploymentId, offering, @@ -43,8 +41,8 @@ export function initializeMetadata({ build_sha: initializerContext.env.packageInfo.buildSha, build_sha_short: initializerContext.env.packageInfo.buildShaShort, project_type: cloud.serverless.projectType, - // orchestrator_target: 'canary', // TODO: Retrieve this - organizationKey: orgId, + orchestrator_target: cloud.serverless.orchestratorTarget, + organizationKey: cloud.organizationId, trial_end_date: cloud.trialEndDate, is_elastic_staff: cloud.isElasticStaffOwned, }); 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 c7fdd236cd9a8..92798581c8507 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 @@ -46,7 +46,7 @@ describe('MetadataService', () => { build_sha: 'abcdefghijklmnopqrstux', build_sha_short: 'abcde', project_type: 'project-type', - organizationKey: 'FAKE_ID', + organizationKey: 'organization-id', is_elastic_staff: true, }; @@ -62,7 +62,7 @@ describe('MetadataService', () => { project_type: 'project-type', }, organization: { - key: 'FAKE_ID', + key: 'organization-id', is_elastic_staff: true, }, }; 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 89273a7b0cda2..0b52f8686bbc9 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 @@ -95,7 +95,7 @@ describe('Cloud Experiments server plugin', () => { build_sha_short: initializerContext.env.packageInfo.buildShaShort, }, organization: { - key: 'FAKE_ID', + key: 'organization-id', trial_end_date: expect.any(Date), in_trial: false, is_elastic_staff: true, From 7d4f0657963e60db14cd2cc0b40cf1ea9c67893a Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:09:25 +0000 Subject: [PATCH 14/41] [CI] Auto-commit changed files from 'node scripts/lint_ts_projects --fix' --- .../core-feature-flags-browser-internal/tsconfig.json | 2 ++ .../lifecycle/core-lifecycle-server-internal/tsconfig.json | 3 ++- .../rendering/core-rendering-server-internal/tsconfig.json | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) 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 index b873c0b4c2e45..3ed73d73e75a3 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/tsconfig.json @@ -20,5 +20,7 @@ "@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/lifecycle/core-lifecycle-server-internal/tsconfig.json b/packages/core/lifecycle/core-lifecycle-server-internal/tsconfig.json index 6e97b6b1fec92..5f7939e82e533 100644 --- a/packages/core/lifecycle/core-lifecycle-server-internal/tsconfig.json +++ b/packages/core/lifecycle/core-lifecycle-server-internal/tsconfig.json @@ -36,7 +36,8 @@ "@kbn/core-user-settings-server-internal", "@kbn/core-security-server-internal", "@kbn/core-user-profile-server-internal", - "@kbn/core-feature-flags-server" + "@kbn/core-feature-flags-server", + "@kbn/core-feature-flags-server-internal" ], "exclude": [ "target/**/*", diff --git a/packages/core/rendering/core-rendering-server-internal/tsconfig.json b/packages/core/rendering/core-rendering-server-internal/tsconfig.json index 2689069f79d79..e02e6e8c57838 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", + "@kbn/core-feature-flags-server-internal", ], "exclude": [ "target/**/*", From 3a2e5b6cbbb982fd8f3759eda692230c5785e2d1 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:36:47 +0000 Subject: [PATCH 15/41] [CI] Auto-commit changed files from 'node scripts/generate codeowners' --- .github/CODEOWNERS | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b4237eff609be..528e7bdfc175a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -174,6 +174,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 From 8dc862818e9bd1cac63c1c54b8856bd844ff0548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 26 Jul 2024 18:23:49 +0200 Subject: [PATCH 16/41] Read config later so that the config definition is registered --- .../src/feature_flags_config.ts | 17 ++++++++++++++--- .../src/feature_flags_service.ts | 16 ++++++++-------- .../src/test_helpers/params.ts | 2 +- .../tsconfig.json | 2 +- 4 files changed, 24 insertions(+), 13 deletions(-) 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 index 55b409b826e82..7433d5be47115 100644 --- 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 @@ -7,15 +7,26 @@ */ import type { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; +/** + * @private + */ const configSchema = schema.object({ overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())), }); -export type FeatureFlagsConfig = TypeOf; +/** + * @private + */ +export interface FeatureFlagsConfig { + overrides?: Record; +} -export const featureFlagsConfig: ServiceConfigDescriptor = { +/** + * @private + */ +export const featureFlagsConfig: ServiceConfigDescriptor = { path: 'feature_flags', schema: configSchema, }; 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 index b747c581aaa5b..bb2d4562a8987 100644 --- 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 @@ -36,25 +36,25 @@ export class FeatureFlagsService { private overrides: Record = {}; private context: EvaluationContext = { kind: 'multi' }; - constructor(core: 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) - core.configService.addDynamicConfigPaths(featureFlagsConfig.path, ['overrides']); + this.core.configService.addDynamicConfigPaths(featureFlagsConfig.path, ['overrides']); - core.configService + this.core.configService .atPath(featureFlagsConfig.path) .subscribe(({ overrides = {} }) => { this.overrides = overrides; }); - } - /** - * Setup lifecycle method - */ - public setup(): InternalFeatureFlagsSetup { return { getOverrides: () => this.overrides, setProvider: (provider) => { 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 92f780598a84b..a15ca242232f9 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 @@ -13,7 +13,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/server/mocks'; +import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; const context = mockCoreContext.create(); const httpPreboot = httpServiceMock.createInternalPrebootContract(); diff --git a/packages/core/rendering/core-rendering-server-internal/tsconfig.json b/packages/core/rendering/core-rendering-server-internal/tsconfig.json index e02e6e8c57838..28a22d3d51ca9 100644 --- a/packages/core/rendering/core-rendering-server-internal/tsconfig.json +++ b/packages/core/rendering/core-rendering-server-internal/tsconfig.json @@ -45,8 +45,8 @@ "@kbn/core-i18n-server-internal", "@kbn/core-i18n-server-mocks", "@kbn/apm-config-loader", - "@kbn/core", "@kbn/core-feature-flags-server-internal", + "@kbn/core-feature-flags-server-mocks", ], "exclude": [ "target/**/*", From 6d3dc216dbc148f85132aeb7b2f94147e9206f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Sat, 27 Jul 2024 02:06:12 +0200 Subject: [PATCH 17/41] Add http handler context + docs + example plugin --- examples/feature_flags_example/README.md | 5 + .../feature_flags_example/common/index.ts | 10 + examples/feature_flags_example/kibana.jsonc | 13 ++ .../public/application.tsx | 32 +++ .../public/components/app.tsx | 85 ++++++++ .../feature_flags_example/public/index.ts | 13 ++ .../feature_flags_example/public/plugin.ts | 39 ++++ .../feature_flags_example/public/types.ts | 13 ++ .../feature_flags_example/server/index.ts | 71 +++++++ .../feature_flags_example/server/plugin.ts | 63 ++++++ .../server/routes/index.ts | 42 ++++ examples/feature_flags_example/tsconfig.json | 24 +++ nav-kibana-dev.docnav.json | 10 +- package.json | 1 + .../elasticsearch_route_handler_context.ts | 2 +- packages/core/feature-flags/README.mdx | 145 ++++++++++++++ .../index.ts | 1 + .../feature_flags_request_handler_context.ts | 32 +++ .../core-feature-flags-server/index.ts | 4 +- .../src/{types.ts => contracts.ts} | 0 .../src/feature_flag_definition.ts | 59 ++++++ .../src/request_handler_context.ts | 14 ++ .../src/core_route_handler_context.ts | 11 ++ .../core_route_handler_context_params.mock.ts | 2 + .../tsconfig.json | 3 + .../src/request_handler_context.ts | 2 + .../tsconfig.json | 3 +- tsconfig.base.json | 2 + .../cloud_experiments/README.mdx | 187 +++--------------- yarn.lock | 4 + 30 files changed, 723 insertions(+), 169 deletions(-) create mode 100755 examples/feature_flags_example/README.md 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-server-internal/src/feature_flags_request_handler_context.ts rename packages/core/feature-flags/core-feature-flags-server/src/{types.ts => contracts.ts} (100%) 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 diff --git a/examples/feature_flags_example/README.md b/examples/feature_flags_example/README.md new file mode 100755 index 0000000000000..54ecd4126683d --- /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/index.ts b/examples/feature_flags_example/common/index.ts new file mode 100644 index 0000000000000..ec99bd844071e --- /dev/null +++ b/examples/feature_flags_example/common/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 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 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 0000000000000..c2a855723bdac --- /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 0000000000000..1ea886d869565 --- /dev/null +++ b/examples/feature_flags_example/public/application.tsx @@ -0,0 +1,32 @@ +/* + * 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 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 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 0000000000000..fcde73ba17228 --- /dev/null +++ b/examples/feature_flags_example/public/components/app.tsx @@ -0,0 +1,85 @@ +/* + * 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 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 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 { 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('example-boolean', false); + const str = featureFlags.getStringValue('example-string', 'red'); + const num = featureFlags.getNumberValue('example-number', 1); + + // Use React Hooks to observe feature flags changes + const bool$ = useObservable(featureFlags.getBooleanValue$('example-boolean', false)); + const str$ = useObservable(featureFlags.getStringValue$('example-string', 'red')); + const num$ = useObservable(featureFlags.getNumberValue$('example-number', 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 0000000000000..f080e49179c6b --- /dev/null +++ b/examples/feature_flags_example/public/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 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 0000000000000..d03e9499bf4f2 --- /dev/null +++ b/examples/feature_flags_example/public/plugin.ts @@ -0,0 +1,39 @@ +/* + * 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 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 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 0000000000000..5d88809f1dbb7 --- /dev/null +++ b/examples/feature_flags_example/public/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 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 0000000000000..965cbed702a50 --- /dev/null +++ b/examples/feature_flags_example/server/index.ts @@ -0,0 +1,71 @@ +/* + * 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 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 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'; + +export const featureFlags: FeatureFlagDefinitions = [ + { + key: 'example-boolean', + 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: 'example-string', + 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: 'example-number', + 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 0000000000000..c87977228e5d6 --- /dev/null +++ b/examples/feature_flags_example/server/plugin.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 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 or the Server + * Side Public License, v 1. + */ + +import type { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '@kbn/core/server'; +import { combineLatest } from 'rxjs'; + +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 + Promise.all([ + core.featureFlags.getBooleanValue('example-boolean', false), + core.featureFlags.getStringValue('example-string', 'white'), + core.featureFlags.getNumberValue('example-number', 1), + ]).then(([bool, str, num]) => { + this.logger.info(`The feature flags are: + - example-boolean: ${bool} + - example-string: ${str} + - example-number: ${num} + `); + }); + + // Observable form: when we need to react to the changes + combineLatest([ + core.featureFlags.getBooleanValue$('example-boolean', false), + core.featureFlags.getStringValue$('example-string', 'red'), + core.featureFlags.getNumberValue$('example-number', 1), + ]).subscribe(([bool, str, num]) => { + this.logger.info(`The observed feature flags are: + - example-boolean: ${bool} + - example-string: ${str} + - example-number: ${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 0000000000000..5a34474bad905 --- /dev/null +++ b/examples/feature_flags_example/server/routes/index.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 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 or the Server + * Side Public License, v 1. + */ + +import type { IRouter } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; + +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('example-number', 1), + }, + }); + } + ); +} diff --git a/examples/feature_flags_example/tsconfig.json b/examples/feature_flags_example/tsconfig.json new file mode 100644 index 0000000000000..bbd68332f3d37 --- /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/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 60d0494de9c71..b13448ea6dd0c 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -108,10 +108,6 @@ }, { "id": "kibDevDocsEmbeddables" - }, - { - "id": "kibCloudExperimentsPlugin", - "label": "A/B testing on Elastic Cloud" } ] }, @@ -177,6 +173,10 @@ }, { "id": "kibDevTutorialCcsSetup" + }, + { + "id": "kibFeatureFlagsService", + "label": "Feature Flags" } ] }, @@ -618,4 +618,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 216a864250f44..ba29d54090f61 100644 --- a/package.json +++ b/package.json @@ -498,6 +498,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", 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 0b4a1474a88e8..7baae4c70b62b 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 @@ -14,7 +14,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 0000000000000..906c238bfb69a --- /dev/null +++ b/packages/core/feature-flags/README.mdx @@ -0,0 +1,145 @@ +--- +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. 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 index fe7a95e2f7348..b11d614bd9200 100644 --- a/packages/core/feature-flags/core-feature-flags-server-internal/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server-internal/index.ts @@ -8,3 +8,4 @@ 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/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 0000000000000..f2617aff0fb9b --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_request_handler_context.ts @@ -0,0 +1,32 @@ +/* + * 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 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 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/index.ts b/packages/core/feature-flags/core-feature-flags-server/index.ts index 450e26b5ad1f7..39f9924eed416 100644 --- a/packages/core/feature-flags/core-feature-flags-server/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server/index.ts @@ -6,4 +6,6 @@ * Side Public License, v 1. */ -export type { EvaluationContext, FeatureFlagsSetup, FeatureFlagsStart } from './src/types'; +export type { EvaluationContext, 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/src/types.ts b/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts similarity index 100% rename from packages/core/feature-flags/core-feature-flags-server/src/types.ts rename to packages/core/feature-flags/core-feature-flags-server/src/contracts.ts 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 0000000000000..36dd0a7da166d --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/src/feature_flag_definition.ts @@ -0,0 +1,59 @@ +/* + * 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 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 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 0000000000000..07dac24c792a8 --- /dev/null +++ b/packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.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 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 or the Server + * Side Public License, v 1. + */ + +import type { FeatureFlagsStart } from '..'; + +export type FeatureFlagsRequestHandlerContext = Pick< + FeatureFlagsStart, + 'getBooleanValue' | 'getStringValue' | 'getNumberValue' +>; 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 85290fc62698c..4c57875cf10a6 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 @@ -32,12 +32,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; @@ -52,6 +55,7 @@ export interface CoreRouteHandlerContextParams { * @internal */ export class CoreRouteHandlerContext implements CoreRequestHandlerContext { + #featureFlags?: CoreFeatureFlagsRouteHandlerContext; #elasticsearch?: CoreElasticsearchRouteHandlerContext; #savedObjects?: CoreSavedObjectsRouteHandlerContext; #uiSettings?: CoreUiSettingsRouteHandlerContext; @@ -64,6 +68,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 735b9e504b3a3..976cd9610eeaf 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 @@ -6,6 +6,7 @@ * 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'; @@ -15,6 +16,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 9e5ab96901e86..99c86608d99de 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/request_handler_context.ts b/packages/core/http/core-http-request-handler-context-server/src/request_handler_context.ts index 735b30abc3b76..d6c127d111c15 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 @@ -13,6 +13,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. @@ -31,6 +32,7 @@ import type { UserProfileRequestHandlerContext } from '@kbn/core-user-profile-se export interface CoreRequestHandlerContext { savedObjects: SavedObjectsRequestHandlerContext; elasticsearch: ElasticsearchRequestHandlerContext; + featureFlags: FeatureFlagsRequestHandlerContext; uiSettings: UiSettingsRequestHandlerContext; deprecations: DeprecationsRequestHandlerContext; security: SecurityRequestHandlerContext; 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 4606770c753d7..905a13801f223 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/tsconfig.base.json b/tsconfig.base.json index ed4167ae23781..8fb82ed4d58bf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -890,6 +890,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_integrations/cloud_experiments/README.mdx b/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx index 2dc4eb566210a..6ef38ba1614af 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/yarn.lock b/yarn.lock index f7f91d507d6b5..0eded9ae839f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5045,6 +5045,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 "" From cd414b50c3c416b4fe06ae5a531793979a591979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Sat, 27 Jul 2024 02:24:31 +0200 Subject: [PATCH 18/41] Update limits --- packages/kbn-optimizer/limits.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index b044ba8e093f4..d85818facfb9c 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: 55082 - core: 435325 + core: 564663 crossClusterReplication: 65408 customIntegrations: 22034 dashboard: 52967 @@ -55,7 +55,7 @@ pageLoadAssetSize: expressionLegacyMetricVis: 23121 expressionMetric: 22238 expressionMetricVis: 23121 - expressionPartitionVis: 29700 + expressionPartitionVis: 44720 expressionRepeatImage: 22341 expressionRevealImage: 25675 expressions: 140958 @@ -155,7 +155,7 @@ pageLoadAssetSize: spaces: 57868 stackAlerts: 58316 stackConnectors: 67227 - synthetics: 40958 + synthetics: 55971 telemetry: 51957 telemetryManagementSection: 38586 threatIntelligence: 44299 From 78cdc41fd83098df97e49a75ab163d07443a8bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Sat, 27 Jul 2024 02:31:47 +0200 Subject: [PATCH 19/41] Fix mocks and dynamic config references --- packages/core/feature-flags/README.mdx | 13 +++++++++++++ .../core-feature-flags-browser-mocks/index.ts | 11 +++++++---- .../core-feature-flags-server-mocks/index.ts | 7 ++++--- .../config/check_dynamic_config.test.ts | 2 ++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/core/feature-flags/README.mdx b/packages/core/feature-flags/README.mdx index 906c238bfb69a..d1e3583aaf2b8 100644 --- a/packages/core/feature-flags/README.mdx +++ b/packages/core/feature-flags/README.mdx @@ -143,3 +143,16 @@ feature_flags.overrides: > [!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-mocks/index.ts b/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts index bd2587e4348a4..b93541eef1dcb 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts @@ -9,6 +9,7 @@ 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 { @@ -19,11 +20,13 @@ const createFeatureFlagsSetup = (): jest.Mocked => { const createFeatureFlagsStart = (): jest.Mocked => { return { - addHandler: jest.fn(), appendContext: jest.fn().mockImplementation(Promise.resolve), - getBooleanValue: jest.fn(), - getNumberValue: jest.fn(), - getStringValue: 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)), }; }; 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 index 1517e3b1dc539..37c90ebff57d5 100644 --- a/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts @@ -12,6 +12,7 @@ import type { FeatureFlagsService, InternalFeatureFlagsSetup, } from '@kbn/core-feature-flags-server-internal'; +import { of } from 'rxjs'; const createFeatureFlagsInternalSetup = (): jest.Mocked => { return { @@ -33,9 +34,9 @@ const createFeatureFlagsStart = (): jest.Mocked => { getBooleanValue: jest.fn().mockImplementation(async (_, fallback) => fallback), getNumberValue: jest.fn().mockImplementation(async (_, fallback) => fallback), getStringValue: jest.fn().mockImplementation(async (_, fallback) => fallback), - getBooleanValue$: jest.fn(), - getStringValue$: jest.fn(), - getNumberValue$: jest.fn(), + getBooleanValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)), + getStringValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)), + getNumberValue$: jest.fn().mockImplementation((_, fallback) => of(fallback)), }; }; 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 7239f051f41e7..e6eb4e3399af1 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 @@ -69,6 +69,8 @@ describe('checking migration metadata changes on all registered SO types', () => // 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', + // Making testing easier by having the ability of overriding the feature flags without the need to restart + 'feature_flags.overrides', ]); }); }); From c92f5098543eea30a5c99447e738b350483cdc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Sat, 27 Jul 2024 02:34:03 +0200 Subject: [PATCH 20/41] Fix test --- test/plugin_functional/test_suites/core_plugins/rendering.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index cab5ad0abb6c4..9a31d8ad33794 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -130,8 +130,6 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'enterpriseSearch.canDeployEntSearch (boolean)', 'enterpriseSearch.host (string)', 'enterpriseSearch.ui.enabled (boolean)', - // No PII. This is an escape patch to override the flag resolution mechanism for testing or quick fixes. - 'feature_flags.overrides (record)', 'home.disableWelcomeScreen (boolean)', 'management.deeplinks.navLinkStatus (string)', 'map.emsFileApiUrl (string)', From 2c00b35225f8b995c492c8d046bb9d632b89f216 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Sat, 27 Jul 2024 00:48:07 +0000 Subject: [PATCH 21/41] [CI] Auto-commit changed files from 'node scripts/generate codeowners' --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 528e7bdfc175a..48d56b3958903 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -448,6 +448,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 From 0f6f9087e6086dfe8c838c2eeef1693f990c1deb Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Sat, 27 Jul 2024 01:21:19 +0000 Subject: [PATCH 22/41] [CI] Auto-commit changed files from 'node scripts/build_plugin_list_docs' --- docs/developer/plugin-list.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 00b2bd8b6a624..651a72d5b704c 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -503,8 +503,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] From 42394a448c555d20d62e772a9501e790891678b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Sat, 27 Jul 2024 08:23:54 +0200 Subject: [PATCH 23/41] Address CI errors --- examples/feature_flags_example/server/plugin.ts | 2 +- .../src/internal_core_setup.mock.ts | 2 +- .../integration_tests/config/check_dynamic_config.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/feature_flags_example/server/plugin.ts b/examples/feature_flags_example/server/plugin.ts index c87977228e5d6..c87d93ef4c3af 100644 --- a/examples/feature_flags_example/server/plugin.ts +++ b/examples/feature_flags_example/server/plugin.ts @@ -33,7 +33,7 @@ export class FeatureFlagsExamplePlugin implements Plugin { public start(core: CoreStart) { // Promise form: when we need to fetch it once, like in an HTTP request - Promise.all([ + void Promise.all([ core.featureFlags.getBooleanValue('example-boolean', false), core.featureFlags.getStringValue('example-string', 'white'), core.featureFlags.getNumberValue('example-number', 1), 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 316440aa92098..ceee113ca4878 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 @@ -37,7 +37,7 @@ export function createInternalCoreSetupMock() { context: contextServiceMock.createSetupContract(), docLinks: docLinksServiceMock.createSetupContract(), elasticsearch: elasticsearchServiceMock.createInternalSetup(), - featureFlags: coreFeatureFlagsMock.createSetup(), + featureFlags: coreFeatureFlagsMock.createInternalSetup(), http: httpServiceMock.createInternalSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createInternalSetupContract(), 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 e6eb4e3399af1..625242adfced9 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 @@ -66,11 +66,11 @@ describe('checking migration metadata changes on all registered SO types', () => */ 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', - // Making testing easier by having the ability of overriding the feature flags without the need to restart - 'feature_flags.overrides', ]); }); }); From 6dc2f510f561cf6083af0b63d291a4df52a74cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Sat, 27 Jul 2024 09:12:09 +0200 Subject: [PATCH 24/41] Remove export from src/core/server --- src/core/server/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 19275349a1dd9..097cc04db93ab 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -75,7 +75,6 @@ export type { FeatureFlagsStart, FeatureFlagsSetup, } from '@kbn/core-feature-flags-server'; -export { ServerProviderEvents } from '@kbn/core-feature-flags-server'; export type { Capabilities } from '@kbn/core-capabilities-common'; export type { CapabilitiesProvider, From f8034232b4af63efafeec83a290dfe140f3715c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Sat, 27 Jul 2024 14:41:49 +0200 Subject: [PATCH 25/41] Add request handler context to the mocks --- .../core-feature-flags-server-mocks/index.ts | 15 ++++++++++++++- src/core/server/mocks.ts | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) 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 index 37c90ebff57d5..80c3181313d98 100644 --- a/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts @@ -7,7 +7,11 @@ */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { FeatureFlagsSetup, FeatureFlagsStart } from '@kbn/core-feature-flags-server'; +import type { + FeatureFlagsRequestHandlerContext, + FeatureFlagsSetup, + FeatureFlagsStart, +} from '@kbn/core-feature-flags-server'; import type { FeatureFlagsService, InternalFeatureFlagsSetup, @@ -40,6 +44,14 @@ const createFeatureFlagsStart = (): jest.Mocked => { }; }; +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), @@ -53,4 +65,5 @@ export const coreFeatureFlagsMock = { createInternalSetup: createFeatureFlagsInternalSetup, createSetup: createFeatureFlagsSetup, createStart: createFeatureFlagsStart, + createRequestHandlerContext, }; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index d33676292cdfa..9b58b1a75f393 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -22,6 +22,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'; @@ -120,6 +121,7 @@ function pluginInitializerContextMock(config: T = {} as T) { function createCoreRequestHandlerContextMock() { return { + featureFlags: coreFeatureFlagsMock.createRequestHandlerContext(), savedObjects: { client: savedObjectsClientMock.create(), typeRegistry: savedObjectsTypeRegistryMock.create(), From 3f77d4b5befa2c49084176cd6e645d5116270d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Sat, 27 Jul 2024 17:13:25 +0200 Subject: [PATCH 26/41] Add featureFlags' mocks to Fleet's storybook mocks --- x-pack/plugins/fleet/.storybook/context/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/fleet/.storybook/context/index.tsx b/x-pack/plugins/fleet/.storybook/context/index.tsx index 3b2d23e59291a..67ed1c8aa6845 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 }), From 9541f7eef430eae0ca1f6dfb139d917af573d5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 29 Jul 2024 21:06:30 +0200 Subject: [PATCH 27/41] Update package types of the new packages --- .../core-feature-flags-browser-internal/kibana.jsonc | 2 +- .../feature-flags/core-feature-flags-browser-mocks/kibana.jsonc | 2 +- .../core/feature-flags/core-feature-flags-browser/kibana.jsonc | 2 +- .../core-feature-flags-server-internal/kibana.jsonc | 2 +- .../feature-flags/core-feature-flags-server-mocks/kibana.jsonc | 2 +- .../core/feature-flags/core-feature-flags-server/kibana.jsonc | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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 index eea26603cbe06..150509b99f519 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-common", + "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-mocks/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc index 9ce23dd0802e8..0917a098841c4 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-common", + "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/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc index ececde79a7549..56187119509b9 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc +++ b/packages/core/feature-flags/core-feature-flags-browser/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-common", + "type": "shared-browser", "id": "@kbn/core-feature-flags-browser", "owner": "@elastic/kibana-core" } 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 index 21d7e6688cf9a..60a01597c0454 100644 --- a/packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc +++ b/packages/core/feature-flags/core-feature-flags-server-internal/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-common", + "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-mocks/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc index e7f8905510705..69b03f0badbdc 100644 --- a/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-common", + "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/kibana.jsonc b/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc index 9b27818c61bcc..dc896ed83b97b 100644 --- a/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc +++ b/packages/core/feature-flags/core-feature-flags-server/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-common", + "type": "shared-server", "id": "@kbn/core-feature-flags-server", "owner": "@elastic/kibana-core" } From 320b7178532ab681dd6be8767b7081f9acdcf154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 30 Jul 2024 10:27:26 +0200 Subject: [PATCH 28/41] Add jsdocs to the setup contract --- .../src/feature_flags_service.ts | 3 +++ 1 file changed, 3 insertions(+) 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 index 0bcb0ea18c48a..3197ff81e5fe1 100644 --- 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 @@ -24,6 +24,9 @@ import { filter, map, startWith, Subject } from 'rxjs'; * @private */ export interface FeatureFlagsSetupDeps { + /** + * Used to read the flag overrides set up in the configuration file. + */ injectedMetadata: InternalInjectedMetadataSetup; } From 3dc6060fee3aba60eb5d78e0376dc7c5145e22ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Thu, 1 Aug 2024 19:36:57 +0200 Subject: [PATCH 29/41] Update packages/core/feature-flags/core-feature-flags-browser/src/types.ts Co-authored-by: Jean-Louis Leysens --- .../core/feature-flags/core-feature-flags-browser/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c7f8da0ae81d4..dbb3a572312eb 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/src/types.ts +++ b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts @@ -17,7 +17,7 @@ import type { Observable } from 'rxjs'; export type EvaluationContext = OpenFeatureEvaluationContext & { /** * We use multi-context so that we can apply segmentation rules at different levels (`organization`/`kibana`). - * * `organization` includes any information that it's common to all the projects/deployments in an organization. An example is the in_trial status. + * * `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`. From 5881d9e9c852ee87ff3aa33a8ea16eef59ca58f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 2 Aug 2024 01:31:26 +0200 Subject: [PATCH 30/41] Improve types of `EvaluationContext` --- .../core-feature-flags-browser/src/types.ts | 97 +++++++++++-------- .../src/contracts.ts | 97 +++++++++++-------- 2 files changed, 114 insertions(+), 80 deletions(-) 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 index dbb3a572312eb..a3e6ca8a9d389 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/src/types.ts +++ b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts @@ -12,48 +12,65 @@ 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 = OpenFeatureEvaluationContext & { - /** - * 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`. - * @public - * - * @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', - * } - * } - */ - kind?: 'multi' | 'organization' | 'kibana'; -}; +export type EvaluationContext = + | { + /** + * Multi-context format. The sub-contexts are provided in their nested properties. + */ + kind: 'multi'; + /** + * The Elastic Cloud organization-specific context. + */ + organization?: OpenFeatureEvaluationContext; + /** + * The deployment/project-specific context. + */ + kibana?: OpenFeatureEvaluationContext; + } + | (OpenFeatureEvaluationContext & { + /** + * The sub-context that it's updated. Defaults to `kibana`. + */ + kind?: 'organization' | 'kibana'; + }); /** * Setup contract of the Feature Flags Service 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 index c69bf0250337c..beb0cf5aae4b3 100644 --- a/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts +++ b/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts @@ -12,48 +12,65 @@ 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 = OpenFeatureEvaluationContext & { - /** - * We use multi-context so that we can apply segmentation rules at different levels (`organization`/`kibana`). - * * `organization` includes any information that it's 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`. - * @public - * - * @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', - * } - * } - */ - kind?: 'multi' | 'organization' | 'kibana'; -}; +export type EvaluationContext = + | { + /** + * Multi-context format. The sub-contexts are provided in their nested properties. + */ + kind: 'multi'; + /** + * The Elastic Cloud organization-specific context. + */ + organization?: OpenFeatureEvaluationContext; + /** + * The deployment/project-specific context. + */ + kibana?: OpenFeatureEvaluationContext; + } + | (OpenFeatureEvaluationContext & { + /** + * The sub-context that it's updated. Defaults to `kibana`. + */ + kind?: 'organization' | 'kibana'; + }); /** * Setup contract of the Feature Flags Service From bc268b822fa43734b8f0ba744b3c391e73837fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 2 Aug 2024 16:36:37 +0200 Subject: [PATCH 31/41] Update x-pack/plugins/cloud_integrations/cloud_experiments/common/metadata_service/initialize_metadata.ts Co-authored-by: Jean-Louis Leysens --- .../common/metadata_service/initialize_metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1cba103fe75dd..ff9d8b9715ce1 100644 --- 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 @@ -59,5 +59,5 @@ export function initializeMetadata({ } }) ) - .subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable + .subscribe(); // This subscription will stop when the metadataService stops because it completes the Observable } From eeacb858cf01b78e39d6d3288b5e3757ecf73d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 2 Aug 2024 16:48:50 +0200 Subject: [PATCH 32/41] Add assertions to "with overrides" tests --- .../src/feature_flags_service.test.ts | 7 +++++++ .../src/feature_flags_service.test.ts | 7 +++++++ 2 files changed, 14 insertions(+) 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 index f3be02fe3834a..0ff026288553c 100644 --- 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 @@ -264,8 +264,15 @@ describe('FeatureFlagsService Browser', () => { }); 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-server-internal/src/feature_flags_service.test.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.test.ts index 65f601e43b0f1..1de273a4839c2 100644 --- 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 @@ -229,10 +229,17 @@ describe('FeatureFlagsService Server', () => { }); 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); }); }); From 59c2d99db229a72499c9c7e8592fbbae09ee5c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 2 Aug 2024 16:59:14 +0200 Subject: [PATCH 33/41] Use `switchMap` instead of `mergeMap` --- .../src/feature_flags_service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 826fa2591bfa4..c0c32b53bc182 100644 --- 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 @@ -17,7 +17,7 @@ import type { Logger } from '@kbn/logging'; import apm from 'elastic-apm-node'; import { type Client, OpenFeature, ServerProviderEvents } from '@openfeature/server-sdk'; import deepMerge from 'deepmerge'; -import { filter, mergeMap, startWith, Subject } from 'rxjs'; +import { filter, switchMap, startWith, Subject } from 'rxjs'; import { type FeatureFlagsConfig, featureFlagsConfig } from './feature_flags_config'; /** @@ -99,14 +99,14 @@ export class FeatureFlagsService { ), getBooleanValue$: (flagName, fallbackValue) => { return observeFeatureFlag$(flagName).pipe( - mergeMap(() => + switchMap(() => this.evaluateFlag(this.featureFlagsClient.getBooleanValue, flagName, fallbackValue) ) ); }, getStringValue$: (flagName: string, fallbackValue: Value) => { return observeFeatureFlag$(flagName).pipe( - mergeMap(() => + switchMap(() => this.evaluateFlag( this.featureFlagsClient.getStringValue, flagName, @@ -117,7 +117,7 @@ export class FeatureFlagsService { }, getNumberValue$: (flagName: string, fallbackValue: Value) => { return observeFeatureFlag$(flagName).pipe( - mergeMap(() => + switchMap(() => this.evaluateFlag( this.featureFlagsClient.getNumberValue, flagName, From 9026d328539d35c7602197471a67036ed6528478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 2 Aug 2024 17:26:11 +0200 Subject: [PATCH 34/41] Report in APM when the provider init is taking too long --- .../src/feature_flags_service.test.ts | 4 ++++ .../src/feature_flags_service.ts | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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 index 0ff026288553c..c0ddd691675c7 100644 --- 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 @@ -73,6 +73,7 @@ describe('FeatureFlagsService Browser', () => { 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); @@ -80,6 +81,9 @@ describe('FeatureFlagsService Browser', () => { 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.') + ); }); }); 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 index 0f6b3eda7dedf..09e7eb59a99e8 100644 --- 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 @@ -137,7 +137,11 @@ export class FeatureFlagsService { new Promise((resolve) => { timeoutId = setTimeout(resolve, 2 * 1000); }).then(() => { - this.logger.warn('The feature flags provider took too long to initialize'); + 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); From acaf70b3b0f0d7c01f93dfe7d73fde5faf6f2729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 5 Aug 2024 12:20:37 +0200 Subject: [PATCH 35/41] Fix missing JSDocs --- .../index.ts | 2 +- .../src/feature_flags_service.ts | 13 ++++++++-- .../core-feature-flags-browser-mocks/index.ts | 12 ++++++++++ .../core-feature-flags-browser/src/types.ts | 12 ++++++++++ .../src/feature_flags_config.ts | 9 +++++++ .../src/feature_flags_service.ts | 8 +++++++ .../core-feature-flags-server-mocks/index.ts | 18 ++++++++++++++ .../src/contracts.ts | 12 ++++++++++ .../src/request_handler_context.ts | 3 +++ .../src/preboot_request_handler_context.ts | 12 ++++++++++ .../src/request_handler_context.ts | 24 +++++++++++++++++++ 11 files changed, 122 insertions(+), 3 deletions(-) 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 index b2cdfd473fc5f..14e134784300a 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { FeatureFlagsService } from './src/feature_flags_service'; +export { FeatureFlagsService, type FeatureFlagsSetupDeps } from './src/feature_flags_service'; 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 index 09e7eb59a99e8..16a1499b80c00 100644 --- 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 @@ -31,6 +31,10 @@ export interface FeatureFlagsSetupDeps { injectedMetadata: InternalInjectedMetadataSetup; } +/** + * The browser-side Feature Flags Service + * @private + */ export class FeatureFlagsService { private readonly featureFlagsClient: Client; private readonly logger: Logger; @@ -38,6 +42,10 @@ export class FeatureFlagsService { 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(); @@ -46,9 +54,10 @@ export class FeatureFlagsService { /** * Setup lifecycle method + * @param deps {@link FeatureFlagsSetup} including the {@link InternalInjectedMetadataSetup} used to retrieve the feature flags. */ - public setup({ injectedMetadata }: FeatureFlagsSetupDeps): FeatureFlagsSetup { - const featureFlagsInjectedMetadata = injectedMetadata.getFeatureFlags(); + public setup(deps: FeatureFlagsSetupDeps): FeatureFlagsSetup { + const featureFlagsInjectedMetadata = deps.injectedMetadata.getFeatureFlags(); if (featureFlagsInjectedMetadata) { this.overrides = featureFlagsInjectedMetadata.overrides; } 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 index b93541eef1dcb..31cd462ae9c2e 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts @@ -38,8 +38,20 @@ const createFeatureFlagsServiceMock = (): jest.Mocked(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-server-internal/src/feature_flags_config.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_config.ts index 7433d5be47115..a80e82208e7dd 100644 --- 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 @@ -10,6 +10,7 @@ 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({ @@ -17,6 +18,7 @@ const configSchema = schema.object({ }); /** + * Type definition of the Feature Flags configuration * @private */ export interface FeatureFlagsConfig { @@ -24,9 +26,16 @@ export interface FeatureFlagsConfig { } /** + * 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_service.ts b/packages/core/feature-flags/core-feature-flags-server-internal/src/feature_flags_service.ts index c0c32b53bc182..ed29dfe492069 100644 --- 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 @@ -31,12 +31,20 @@ export interface InternalFeatureFlagsSetup extends FeatureFlagsSetup { 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(); 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 index 80c3181313d98..1ab26a5a7daae 100644 --- a/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts @@ -60,10 +60,28 @@ const createFeatureFlagsServiceMock = (): jest.Mocked; /** * 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/request_handler_context.ts b/packages/core/feature-flags/core-feature-flags-server/src/request_handler_context.ts index 07dac24c792a8..90f00173a6ed2 100644 --- 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 @@ -8,6 +8,9 @@ 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/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 62caefe5619c6..366feafd968ad 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 @@ -10,22 +10,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 d6c127d111c15..3ef4a76231aca 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 @@ -30,12 +30,33 @@ import type { FeatureFlagsRequestHandlerContext } from '@kbn/core-feature-flags- * @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; } @@ -45,6 +66,9 @@ export interface CoreRequestHandlerContext { * @public */ export interface RequestHandlerContext extends RequestHandlerContextBase { + /** + * Promise that resolves the {@link CoreRequestHandlerContext} + */ core: Promise; } From 7f41811bfa141c497c43931d2f2c53e008ff9a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 7 Aug 2024 16:58:49 +0200 Subject: [PATCH 36/41] Throw if `setProvider` is called twice --- .../src/feature_flags_service.test.ts | 9 +++++++++ .../src/feature_flags_service.ts | 3 +++ .../src/feature_flags_service.test.ts | 9 +++++++++ .../src/feature_flags_service.ts | 10 +++++++++- 4 files changed, 30 insertions(+), 1 deletion(-) 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 index c0ddd691675c7..3d854abaa7bc0 100644 --- 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 @@ -50,6 +50,15 @@ describe('FeatureFlagsService Browser', () => { 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; 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 index 16a1499b80c00..e1a6cae8228e9 100644 --- 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 @@ -63,6 +63,9 @@ export class FeatureFlagsService { } 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), 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 index 1de273a4839c2..fc3792786e504 100644 --- 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 @@ -49,6 +49,15 @@ describe('FeatureFlagsService Server', () => { 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', () => { 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 index ed29dfe492069..ab5e20c24864c 100644 --- 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 @@ -15,7 +15,12 @@ import type { } from '@kbn/core-feature-flags-server'; import type { Logger } from '@kbn/logging'; import apm from 'elastic-apm-node'; -import { type Client, OpenFeature, ServerProviderEvents } from '@openfeature/server-sdk'; +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'; @@ -67,6 +72,9 @@ export class FeatureFlagsService { 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), From 43f366d4e5044549356704b4d1f5eda84df0a3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 19 Aug 2024 19:12:01 +0200 Subject: [PATCH 37/41] Use constants in the example plugin --- .../common/feature_flags.ts | 11 +++++++ .../public/components/app.tsx | 29 +++++++++++-------- .../feature_flags_example/server/index.ts | 11 +++++-- .../feature_flags_example/server/plugin.ts | 29 +++++++++++-------- .../server/routes/index.ts | 3 +- 5 files changed, 55 insertions(+), 28 deletions(-) create mode 100644 examples/feature_flags_example/common/feature_flags.ts 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 0000000000000..df13cc7154c35 --- /dev/null +++ b/examples/feature_flags_example/common/feature_flags.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 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 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/public/components/app.tsx b/examples/feature_flags_example/public/components/app.tsx index fcde73ba17228..0738e635d4a64 100644 --- a/examples/feature_flags_example/public/components/app.tsx +++ b/examples/feature_flags_example/public/components/app.tsx @@ -19,6 +19,11 @@ import { 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 { @@ -29,14 +34,14 @@ interface FeatureFlagsExampleAppDeps { export const FeatureFlagsExampleApp = ({ featureFlags }: FeatureFlagsExampleAppDeps) => { // Fetching the feature flags synchronously - const bool = featureFlags.getBooleanValue('example-boolean', false); - const str = featureFlags.getStringValue('example-string', 'red'); - const num = featureFlags.getNumberValue('example-number', 1); + 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$('example-boolean', false)); - const str$ = useObservable(featureFlags.getStringValue$('example-string', 'red')); - const num$ = useObservable(featureFlags.getNumberValue$('example-number', 1)); + const bool$ = useObservable(featureFlags.getBooleanValue$(FeatureFlagExampleBoolean, false)); + const str$ = useObservable(featureFlags.getStringValue$(FeatureFlagExampleString, 'red')); + const num$ = useObservable(featureFlags.getNumberValue$(FeatureFlagExampleNumber, 1)); return ( <> @@ -64,17 +69,17 @@ export const FeatureFlagsExampleApp = ({ featureFlags }: FeatureFlagsExampleAppD

The feature flags are: - - - + + +

The observed feature flags are: - - - + + +

diff --git a/examples/feature_flags_example/server/index.ts b/examples/feature_flags_example/server/index.ts index 965cbed702a50..668c084a3ea18 100644 --- a/examples/feature_flags_example/server/index.ts +++ b/examples/feature_flags_example/server/index.ts @@ -8,10 +8,15 @@ 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: 'example-boolean', + key: FeatureFlagExampleBoolean, name: 'Example boolean', description: 'This is a demo of a boolean flag', tags: ['example', 'my-plugin'], @@ -30,7 +35,7 @@ export const featureFlags: FeatureFlagDefinitions = [ ], }, { - key: 'example-string', + key: FeatureFlagExampleString, name: 'Example string', description: 'This is a demo of a string flag', tags: ['example', 'my-plugin'], @@ -47,7 +52,7 @@ export const featureFlags: FeatureFlagDefinitions = [ ], }, { - key: 'example-number', + key: FeatureFlagExampleNumber, name: 'Example Number', description: 'This is a demo of a number flag', tags: ['example', 'my-plugin'], diff --git a/examples/feature_flags_example/server/plugin.ts b/examples/feature_flags_example/server/plugin.ts index c87d93ef4c3af..d90d1effbe874 100644 --- a/examples/feature_flags_example/server/plugin.ts +++ b/examples/feature_flags_example/server/plugin.ts @@ -15,6 +15,11 @@ import type { } 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 { @@ -34,27 +39,27 @@ export class FeatureFlagsExamplePlugin implements Plugin { public start(core: CoreStart) { // Promise form: when we need to fetch it once, like in an HTTP request void Promise.all([ - core.featureFlags.getBooleanValue('example-boolean', false), - core.featureFlags.getStringValue('example-string', 'white'), - core.featureFlags.getNumberValue('example-number', 1), + 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: - - example-boolean: ${bool} - - example-string: ${str} - - example-number: ${num} + - ${FeatureFlagExampleBoolean}: ${bool} + - ${FeatureFlagExampleString}: ${str} + - ${FeatureFlagExampleNumber}: ${num} `); }); // Observable form: when we need to react to the changes combineLatest([ - core.featureFlags.getBooleanValue$('example-boolean', false), - core.featureFlags.getStringValue$('example-string', 'red'), - core.featureFlags.getNumberValue$('example-number', 1), + 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: - - example-boolean: ${bool} - - example-string: ${str} - - example-number: ${num} + - ${FeatureFlagExampleBoolean}: ${bool} + - ${FeatureFlagExampleString}: ${str} + - ${FeatureFlagExampleNumber}: ${num} `); }); } diff --git a/examples/feature_flags_example/server/routes/index.ts b/examples/feature_flags_example/server/routes/index.ts index 5a34474bad905..0425ffd4b551c 100644 --- a/examples/feature_flags_example/server/routes/index.ts +++ b/examples/feature_flags_example/server/routes/index.ts @@ -8,6 +8,7 @@ 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 @@ -34,7 +35,7 @@ export function defineRoutes(router: IRouter) { return response.ok({ body: { - number: await featureFlags.getNumberValue('example-number', 1), + number: await featureFlags.getNumberValue(FeatureFlagExampleNumber, 1), }, }); } From 25afa7097d0d2cdc6bb49813cfa4daec239d6eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 19 Aug 2024 19:51:06 +0200 Subject: [PATCH 38/41] Use SDK key in the server side --- .../cloud_integrations/cloud_experiments/server/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d0a7cc44aa674..fa9de11b0dfc9 100755 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/plugin.ts @@ -103,7 +103,7 @@ export class CloudExperimentsPlugin if (!ldConfig) return; - return new LaunchDarklyProvider(ldConfig.client_id, { + return new LaunchDarklyProvider(ldConfig.sdk_key, { logger: this.logger.get('launch-darkly'), application: { id: 'kibana-server', From 9c97aaa0d66ea8effc617693b5a00e46b753eaad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Mon, 19 Aug 2024 20:22:16 +0200 Subject: [PATCH 39/41] Move chat flag evaluations to the route handler --- .../cloud_chat/server/plugin.ts | 11 -------- .../cloud_chat/server/routes/chat.test.ts | 26 +++++-------------- .../cloud_chat/server/routes/chat.ts | 18 +++++-------- 3 files changed, 14 insertions(+), 41 deletions(-) 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 242df76e789a8..a2762b89e124a 100755 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts @@ -10,7 +10,6 @@ import { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; import { registerChatRoute } from './routes'; import type { CloudChatConfigType } from './config'; -import type { ChatVariant } from '../common/types'; interface CloudChatSetupDeps { cloud: CloudSetup; @@ -36,16 +35,6 @@ export class CloudChatPlugin implements Plugin { trialEndDate, trialBuffer, isDev: this.isDev, - getChatVariant: () => - core - .getStartServices() - .then(([{ featureFlags }]) => - featureFlags.getStringValue('cloud-chat.chat-variant', 'header') - ), - getChatDisabledThroughExperiments: () => - core - .getStartServices() - .then(([{ featureFlags }]) => featureFlags.getBooleanValue('cloud-chat.enabled', true)), }); } } 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 94a55b2274a99..ea25ff9801af3 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 735a5db9298c4..e37ed1e935c49 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 }); } From 5c8b3e0dc94e38d33b8447fc17637462d29044e9 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 17 Sep 2024 19:04:56 +0000 Subject: [PATCH 40/41] [CI] Auto-commit changed files from 'node scripts/lint_packages --fix' --- .../core-feature-flags-browser-internal/package.json | 2 +- .../feature-flags/core-feature-flags-browser-mocks/package.json | 2 +- .../core/feature-flags/core-feature-flags-browser/package.json | 2 +- .../core-feature-flags-server-internal/package.json | 2 +- .../feature-flags/core-feature-flags-server-mocks/package.json | 2 +- .../core/feature-flags/core-feature-flags-server/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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 index 93e5c87660c8e..de82d53b1c964 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-internal/package.json +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/package.json @@ -2,5 +2,5 @@ "name": "@kbn/core-feature-flags-browser-internal", "private": true, "version": "1.0.0", - "license": "SSPL-1.0 OR Elastic License 2.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/package.json b/packages/core/feature-flags/core-feature-flags-browser-mocks/package.json index 54462db563661..77e9150ce7834 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-mocks/package.json +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/package.json @@ -2,5 +2,5 @@ "name": "@kbn/core-feature-flags-browser-mocks", "private": true, "version": "1.0.0", - "license": "SSPL-1.0 OR Elastic License 2.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/package.json b/packages/core/feature-flags/core-feature-flags-browser/package.json index 097468f5eba2f..235f52c0521f1 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/package.json +++ b/packages/core/feature-flags/core-feature-flags-browser/package.json @@ -2,5 +2,5 @@ "name": "@kbn/core-feature-flags-browser", "private": true, "version": "1.0.0", - "license": "SSPL-1.0 OR Elastic License 2.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/package.json b/packages/core/feature-flags/core-feature-flags-server-internal/package.json index b226626cf97c2..33383b043fa5c 100644 --- a/packages/core/feature-flags/core-feature-flags-server-internal/package.json +++ b/packages/core/feature-flags/core-feature-flags-server-internal/package.json @@ -2,5 +2,5 @@ "name": "@kbn/core-feature-flags-server-internal", "private": true, "version": "1.0.0", - "license": "SSPL-1.0 OR Elastic License 2.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/package.json b/packages/core/feature-flags/core-feature-flags-server-mocks/package.json index 8e5e9deea30b3..f009e55f76a8e 100644 --- a/packages/core/feature-flags/core-feature-flags-server-mocks/package.json +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/package.json @@ -2,5 +2,5 @@ "name": "@kbn/core-feature-flags-server-mocks", "private": true, "version": "1.0.0", - "license": "SSPL-1.0 OR Elastic License 2.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/package.json b/packages/core/feature-flags/core-feature-flags-server/package.json index 1062176d837c5..d1f18a98a3840 100644 --- a/packages/core/feature-flags/core-feature-flags-server/package.json +++ b/packages/core/feature-flags/core-feature-flags-server/package.json @@ -2,5 +2,5 @@ "name": "@kbn/core-feature-flags-server", "private": true, "version": "1.0.0", - "license": "SSPL-1.0 OR Elastic License 2.0" + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" } \ No newline at end of file From 3f76163a4b31aade1fc30cfb8435dc7eed285ff9 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 17 Sep 2024 19:53:34 +0000 Subject: [PATCH 41/41] [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' --- examples/feature_flags_example/common/feature_flags.ts | 9 +++++---- examples/feature_flags_example/common/index.ts | 9 +++++---- examples/feature_flags_example/public/application.tsx | 9 +++++---- examples/feature_flags_example/public/components/app.tsx | 9 +++++---- examples/feature_flags_example/public/index.ts | 9 +++++---- examples/feature_flags_example/public/plugin.ts | 9 +++++---- examples/feature_flags_example/public/types.ts | 9 +++++---- examples/feature_flags_example/server/index.ts | 9 +++++---- examples/feature_flags_example/server/plugin.ts | 9 +++++---- examples/feature_flags_example/server/routes/index.ts | 9 +++++---- .../core-feature-flags-browser-internal/index.ts | 9 +++++---- .../core-feature-flags-browser-internal/jest.config.js | 9 +++++---- .../src/feature_flags_service.test.ts | 9 +++++---- .../src/feature_flags_service.ts | 9 +++++---- .../core-feature-flags-browser-mocks/index.ts | 9 +++++---- .../core-feature-flags-browser-mocks/jest.config.js | 9 +++++---- .../feature-flags/core-feature-flags-browser/index.ts | 9 +++++---- .../core-feature-flags-browser/src/types.ts | 9 +++++---- .../core-feature-flags-server-internal/index.ts | 9 +++++---- .../core-feature-flags-server-internal/jest.config.js | 9 +++++---- .../src/feature_flags_config.ts | 9 +++++---- .../src/feature_flags_request_handler_context.ts | 9 +++++---- .../src/feature_flags_service.test.ts | 9 +++++---- .../src/feature_flags_service.ts | 9 +++++---- .../core-feature-flags-server-mocks/index.ts | 9 +++++---- .../core-feature-flags-server-mocks/jest.config.js | 9 +++++---- .../feature-flags/core-feature-flags-server/index.ts | 9 +++++---- .../core-feature-flags-server/src/contracts.ts | 9 +++++---- .../src/feature_flag_definition.ts | 9 +++++---- .../src/request_handler_context.ts | 9 +++++---- 30 files changed, 150 insertions(+), 120 deletions(-) diff --git a/examples/feature_flags_example/common/feature_flags.ts b/examples/feature_flags_example/common/feature_flags.ts index df13cc7154c35..fcff25bbd2c42 100644 --- a/examples/feature_flags_example/common/feature_flags.ts +++ b/examples/feature_flags_example/common/feature_flags.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; diff --git a/examples/feature_flags_example/common/index.ts b/examples/feature_flags_example/common/index.ts index ec99bd844071e..37bde8e9843e1 100644 --- a/examples/feature_flags_example/common/index.ts +++ b/examples/feature_flags_example/common/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; diff --git a/examples/feature_flags_example/public/application.tsx b/examples/feature_flags_example/public/application.tsx index 1ea886d869565..eab558d9301bd 100644 --- a/examples/feature_flags_example/public/application.tsx +++ b/examples/feature_flags_example/public/application.tsx @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; diff --git a/examples/feature_flags_example/public/components/app.tsx b/examples/feature_flags_example/public/components/app.tsx index 0738e635d4a64..432e7dc348abc 100644 --- a/examples/feature_flags_example/public/components/app.tsx +++ b/examples/feature_flags_example/public/components/app.tsx @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; diff --git a/examples/feature_flags_example/public/index.ts b/examples/feature_flags_example/public/index.ts index f080e49179c6b..9324fbb56bc2e 100644 --- a/examples/feature_flags_example/public/index.ts +++ b/examples/feature_flags_example/public/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; diff --git a/examples/feature_flags_example/public/plugin.ts b/examples/feature_flags_example/public/plugin.ts index d03e9499bf4f2..915c40dcaafe8 100644 --- a/examples/feature_flags_example/public/plugin.ts +++ b/examples/feature_flags_example/public/plugin.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; diff --git a/examples/feature_flags_example/public/types.ts b/examples/feature_flags_example/public/types.ts index 5d88809f1dbb7..7f3f7107a1385 100644 --- a/examples/feature_flags_example/public/types.ts +++ b/examples/feature_flags_example/public/types.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; diff --git a/examples/feature_flags_example/server/index.ts b/examples/feature_flags_example/server/index.ts index 668c084a3ea18..ad88372a7e11e 100644 --- a/examples/feature_flags_example/server/index.ts +++ b/examples/feature_flags_example/server/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; diff --git a/examples/feature_flags_example/server/plugin.ts b/examples/feature_flags_example/server/plugin.ts index d90d1effbe874..3abd4554eb335 100644 --- a/examples/feature_flags_example/server/plugin.ts +++ b/examples/feature_flags_example/server/plugin.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 { diff --git a/examples/feature_flags_example/server/routes/index.ts b/examples/feature_flags_example/server/routes/index.ts index 0425ffd4b551c..97ce19ec9981b 100644 --- a/examples/feature_flags_example/server/routes/index.ts +++ b/examples/feature_flags_example/server/routes/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index 14e134784300a..e22aeeecd35fb 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts +++ b/packages/core/feature-flags/core-feature-flags-browser-internal/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 index 39f77fe7adebf..be4a9c1b14073 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 = { 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 index 3d854abaa7bc0..596d64c7b77ae 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index e1a6cae8228e9..0f7e572ef5ce0 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index 31cd462ae9c2e..ad8cdae6a5ef1 100644 --- a/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts +++ b/packages/core/feature-flags/core-feature-flags-browser-mocks/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index f715bab35774c..f259faecb6046 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 = { diff --git a/packages/core/feature-flags/core-feature-flags-browser/index.ts b/packages/core/feature-flags/core-feature-flags-browser/index.ts index 44a1be29fccfb..6c79c96f01878 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/index.ts +++ b/packages/core/feature-flags/core-feature-flags-browser/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 { 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 index 27bb6cc7f2c5e..844675aab4603 100644 --- a/packages/core/feature-flags/core-feature-flags-browser/src/types.ts +++ b/packages/core/feature-flags/core-feature-flags-browser/src/types.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index b11d614bd9200..97083327e609d 100644 --- a/packages/core/feature-flags/core-feature-flags-server-internal/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server-internal/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index 6b0fd80dec92d..67b65d2040c54 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 = { 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 index a80e82208e7dd..fe6725456806b 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index f2617aff0fb9b..f0ac4da69b1a9 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 { 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 index fc3792786e504..7bad676b9528b 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index ab5e20c24864c..7b01ebde731fe 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index 1ab26a5a7daae..182f6dbc21102 100644 --- a/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server-mocks/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index 5c7b537679453..bc50c37548c95 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 = { diff --git a/packages/core/feature-flags/core-feature-flags-server/index.ts b/packages/core/feature-flags/core-feature-flags-server/index.ts index 2e81062202500..7538b68686cd9 100644 --- a/packages/core/feature-flags/core-feature-flags-server/index.ts +++ b/packages/core/feature-flags/core-feature-flags-server/index.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 { 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 index ff160dd87306b..34fc3a3a73383 100644 --- a/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts +++ b/packages/core/feature-flags/core-feature-flags-server/src/contracts.ts @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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'; 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 index 36dd0a7da166d..3ea761484fc2a 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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". */ /** 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 index 90f00173a6ed2..25f521e18f1c9 100644 --- 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 @@ -1,9 +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 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 or the Server - * Side Public License, v 1. + * 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 '..';